From 5ad052ffe479341ade5833af0d499620eb08c61a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 19 Feb 2025 21:04:08 +0100 Subject: [PATCH 01/29] Delay console opening for pool VMs. --- .../manager/events/GetDisplayPassword.java | 74 ----------- .../manager/events/PrepareConsole.java | 119 ++++++++++++++++++ .../manager/DisplaySecretMonitor.java | 48 ++++--- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 25 ++-- .../vmaccess/browser/VmAccess-functions.ts | 21 ++-- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 23 ++-- 6 files changed, 191 insertions(+), 119 deletions(-) delete mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java 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 deleted file mode 100644 index f6fa555..0000000 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager.events; - -import java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinition; -import org.jgrapes.core.Event; - -/** - * Gets the current display secret and optionally updates it. - */ -@SuppressWarnings("PMD.DataClass") -public class GetDisplayPassword extends Event { - - private final VmDefinition vmDef; - private final String user; - - /** - * Instantiates a new request for the display secret. - * - * @param vmDef the vm name - * @param user the requesting user - */ - public GetDisplayPassword(VmDefinition vmDef, String user) { - this.vmDef = vmDef; - this.user = user; - } - - /** - * Gets the vm definition. - * - * @return the vm definition - */ - public VmDefinition vmDefinition() { - return vmDef; - } - - /** - * Return the id of the user who has requested the password. - * - * @return the string - */ - public String user() { - return user; - } - - /** - * Return the password. May only be called when the event is completed. - * - * @return the optional - */ - public Optional password() { - if (!isDone()) { - throw new IllegalStateException("Event is not done."); - } - return currentResults().stream().findFirst(); - } -} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java new file mode 100644 index 0000000..ad8f9ce --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java @@ -0,0 +1,119 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +@SuppressWarnings("PMD.DataClass") +public class PrepareConsole extends Event { + + private final VmDefinition vmDef; + private final String user; + private final boolean loginUser; + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no password is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + * @param loginUser login the user + */ + public PrepareConsole(VmDefinition vmDef, String user, + boolean loginUser) { + this.vmDef = vmDef; + this.user = user; + this.loginUser = loginUser; + } + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no password is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + */ + public PrepareConsole(VmDefinition vmDef, String user) { + this(vmDef, user, false); + } + + /** + * Gets the vm definition. + * + * @return the vm definition + */ + public VmDefinition vmDefinition() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + + /** + * Checks if the user should be logged in before allowing access. + * + * @return the loginUser + */ + public boolean loginUser() { + return loginUser; + } + + /** + * Returns `true` if a password is available. May only be called + * when the event is completed. Note that the password returned + * by {@link #password()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean passwordAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the password. May only be called when the event has been + * completed with a valid result (see {@link #passwordAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String password() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} 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 a0809e9..152f91e 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 @@ -50,7 +50,7 @@ import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; @@ -72,7 +72,7 @@ public class DisplaySecretMonitor extends AbstractMonitor { private int passwordValidity = 10; - private final List pendingGets + private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); private final ChannelDictionary channelDictionary; @@ -178,49 +178,59 @@ public class DisplaySecretMonitor */ @Handler @SuppressWarnings("PMD.StringInstantiation") - public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) + public void onPrepareConsole(PrepareConsole event, VmChannel channel) throws ApiException { // Update console user in status var vmStub = VmDefinitionStub.get(client(), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); - vmStub.updateStatus(from -> { + var optVmDef = vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty("consoleUser", event.user()); return status; }); + if (optVmDef.isEmpty()) { + return; + } + var vmDef = optVmDef.get(); + + // Check if access is possible + if (event.loginUser() + ? !vmDef.conditionStatus("Booted").orElse(false) + : !vmDef.conditionStatus("Running").orElse(false)) { + return; + } // Look for secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" - + event.vmDefinition().metadata().getName()); - var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().namespace(), options); + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options); if (stubs.isEmpty()) { // No secret means no password for this VM wanted + event.setResult(null); return; } var stub = stubs.iterator().next(); // Check validity - var model = stub.model().get(); + var secret = stub.model().get(); @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(model.getData() + var expiry = Optional.ofNullable(secret.getData() .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (model.getData().get(DATA_DISPLAY_PASSWORD) != null + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null && stillValid(expiry)) { // Fixed secret, don't touch event.setResult( - new String(model.getData().get(DATA_DISPLAY_PASSWORD))); + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); return; } updatePassword(stub, event); } @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) + private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) throws ApiException { SecureRandom random = null; try { @@ -242,9 +252,9 @@ public class DisplaySecretMonitor var pending = new PendingGet(event, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, new CompletionLock(event, 1500)); - pendingGets.add(pending); + pendingPrepares.add(pending); Event.onCompletion(event, e -> { - pendingGets.remove(pending); + pendingPrepares.remove(pending); }); // Update, will (eventually) trigger confirmation @@ -273,9 +283,9 @@ public class DisplaySecretMonitor @Handler @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingGets) { + synchronized (pendingPrepares) { String vmName = event.vmDefinition().name(); - for (var pending : pendingGets) { + for (var pending : pendingPrepares) { if (pending.event.vmDefinition().name().equals(vmName) && event.vmDefinition().displayPasswordSerial() .map(s -> s >= pending.expectedSerial).orElse(false)) { @@ -293,7 +303,7 @@ public class DisplaySecretMonitor */ @SuppressWarnings("PMD.DataClass") private static class PendingGet { - public final GetDisplayPassword event; + public final PrepareConsole event; public final long expectedSerial; public final CompletionLock lock; @@ -303,7 +313,7 @@ public class DisplaySecretMonitor * @param event the event * @param expectedSerial the expected serial */ - public PendingGet(GetDisplayPassword event, long expectedSerial, + public PendingGet(PrepareConsole event, long expectedSerial, CompletionLock lock) { super(); this.event = event; diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index e283504..3b28d1c 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.AssignVm; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra() - .map(xtra -> xtra.connectionFile(e.password().orElse(null), - preferredIpVersion, deleteConnectionFile)) - .ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user, + model.mode() == ResourceModel.Mode.POOL), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)) + .ifPresent(cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index ec21fb5..31408cb 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const configured = computed(() => previewApi.vmDefinition.spec); const busy = computed(() => previewApi.vmDefinition.spec && (previewApi.vmDefinition.spec.vm.state === 'Running' - && !previewApi.vmDefinition.running + && (previewApi.poolName + ? !previewApi.vmDefinition.booted + : !previewApi.vmDefinition.running) || previewApi.vmDefinition.spec.vm.state === 'Stopped' && previewApi.vmDefinition.running)); const startable = computed(() => previewApi.vmDefinition.spec @@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const booted = computed(() => previewApi.vmDefinition.booted); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.permissions); const osicon = computed(() => { @@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, poolName, vmName, - configured, busy, startable, stoppable, running, inUse, - permissions, osicon }; + configured, busy, startable, stoppable, running, booted, + inUse, permissions, osicon }; }, template: ` @@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
{ if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; vmDefinition.runningConditionSince = new Date(condition.lastTransitionTime); - break; + } else if (condition.type === "Booted") { + vmDefinition.booted = condition.status === "True"; + vmDefinition.bootedConditionSince + = new Date(condition.lastTransitionTime); } - } + }) } else { vmDefinition = {}; } diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 4cc63fa..10b4f48 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -43,8 +43,8 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.manager.events.ChannelTracker; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -483,17 +483,22 @@ public class VmMgmt extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra().map(xtra -> xtra.connectionFile( - e.password().orElse(null), preferredIpVersion, - deleteConnectionFile)).ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, VmsModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)).ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { From e29135282848de191f1075d35e5cefe64bb25fb0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 21 Feb 2025 20:54:27 +0100 Subject: [PATCH 02/29] Prepare usage of guest os command. --- .../runner/qemu/GuestAgentClient.java | 39 ++++++++++++++++++- .../vmoperator/runner/qemu/Runner.java | 9 ++++- .../templates/Standard-VM-latest.ftl.yaml | 4 ++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index f3928f5..afe3d26 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -20,6 +20,7 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.io.Writer; @@ -28,6 +29,8 @@ import java.net.UnixDomainSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; @@ -65,6 +68,8 @@ public class GuestAgentClient extends Component { private EventPipeline rep; private Path socketPath; + private List> guestAgentCmds; + private String guestAgentCmd; private SocketIOChannel gaChannel; private final Queue executing = new LinkedList<>(); @@ -72,6 +77,7 @@ public class GuestAgentClient extends Component { * Instantiates a new guest agent client. * * @param componentChannel the component channel + * @param guestAgentCmds * @throws IOException Signals that an I/O exception has occurred. */ @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", @@ -87,10 +93,20 @@ public class GuestAgentClient extends Component { * forwarded from the {@link Runner} instead. * * @param socketPath the socket path + * @param guestAgentCmds * @param powerdownTimeout */ - /* default */ void configure(Path socketPath) { + @SuppressWarnings("PMD.EmptyCatchBlock") + /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { this.socketPath = socketPath; + try { + this.guestAgentCmds = mapper.convertValue(guestAgentCmds, + mapper.constructType(getClass() + .getDeclaredField("guestAgentCmds").getGenericType())); + } catch (IllegalArgumentException | NoSuchFieldException + | SecurityException e) { + // Cannot happen + } } /** @@ -193,7 +209,7 @@ public class GuestAgentClient extends Component { () -> String.format("(Previous \"guest agent(in)\" is " + "result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { - rep.fire(new OsinfoEvent(response.get("return"))); + processOsInfo(response); } } } catch (JsonProcessingException e) { @@ -201,6 +217,25 @@ public class GuestAgentClient extends Component { } } + private void processOsInfo(ObjectNode response) { + var osInfo = new OsinfoEvent(response.get("return")); + var osId = osInfo.osinfo().get("id").asText(); + for (var cmdDef : guestAgentCmds) { + if (osId.equals(cmdDef.get("osId")) + || "*".equals(cmdDef.get("osId"))) { + guestAgentCmd = cmdDef.get("executable"); + break; + } + } + if (guestAgentCmd == null) { + logger.warning(() -> "No guest agent command for OS " + osId); + } else { + logger.fine(() -> "Guest agent command for OS " + osId + + " is " + guestAgentCmd); + } + rep.fire(osInfo); + } + /** * On closed. * diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index b258e1a..e0cd837 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; @@ -197,6 +198,7 @@ public class Runner extends Component { private static final String QEMU = "qemu"; private static final String SWTPM = "swtpm"; private static final String CLOUD_INIT_IMG = "cloudInitImg"; + private static final String GUEST_AGENT_CMDS = "guestAgentCmds"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -348,11 +350,16 @@ public class Runner extends Component { .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); + var guestAgentCmds = (ArrayNode) tplData.get(GUEST_AGENT_CMDS); + if (guestAgentCmds != null) { + logger.finest( + () -> "GuestAgentCmds: " + guestAgentCmds.toString()); + } // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); - guestAgentClient.configure(config.guestAgentSocket); + guestAgentClient.configure(config.guestAgentSocket, guestAgentCmds); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index e2610ba..3eacfa3 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -233,3 +233,7 @@ + +"guestAgentCmds": + - "osId": "*" + "executable": "/usr/local/libexec/vm-operator-cmd" From 81b128e4a3461039abfde053ed7a32e5a391b811 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Feb 2025 21:24:58 +0100 Subject: [PATCH 03/29] Clarify responsibilities of display secret monitor and reconciler. --- .../manager/DisplaySecretMonitor.java | 210 +--------------- .../manager/DisplaySecretReconciler.java | 224 +++++++++++++++++- .../vmoperator/manager/Reconciler.java | 5 +- webpages/vm-operator/upgrading.md | 24 +- webpages/vm-operator/user-gui.md | 12 +- 5 files changed, 252 insertions(+), 223 deletions(-) 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 152f91e..99c8a11 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 @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 Michael N. Lipp + * Copyright (C) 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,8 +18,6 @@ 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; @@ -28,52 +26,26 @@ import io.kubernetes.client.util.Watch.Response; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.Instant; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -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; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; -import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; -import org.jgrapes.core.CompletionLock; -import org.jgrapes.core.Event; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jose4j.base64url.Base64; /** - * Watches for changes of display secrets. The component supports the - * following configuration properties: - * - * * `passwordValidity`: the validity of the random password in seconds. - * Used to calculate the password expiry time in the generated secret. + * Watches for changes of display secrets. Updates an artifical attribute + * of the pod running the VM in response to force an update of the files + * in the pod that reflect the information from the secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) public class DisplaySecretMonitor extends AbstractMonitor { - private int passwordValidity = 10; - private final List pendingPrepares - = Collections.synchronizedList(new LinkedList<>()); private final ChannelDictionary channelDictionary; /** @@ -93,27 +65,6 @@ public class DisplaySecretMonitor options(options); } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @Override - public void onConfigurationUpdate(ConfigurationUpdate event) { - super.onConfigurationUpdate(event); - event.structured(componentPath()).ifPresent(c -> { - try { - if (c.containsKey("passwordValidity")) { - passwordValidity = Integer - .parseInt((String) c.get("passwordValidity")); - } - } catch (ClassCastException e) { - logger.config("Malformed configuration: " + e.getMessage()); - } - }); - } - @Override protected void prepareMonitoring() throws IOException, ApiException { client(new K8sClient()); @@ -168,157 +119,4 @@ public class DisplaySecretMonitor + "\"}]"), patchOpts); } - - /** - * On get display secrets. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - */ - @Handler - @SuppressWarnings("PMD.StringInstantiation") - public void onPrepareConsole(PrepareConsole event, VmChannel channel) - throws ApiException { - // Update console user in status - var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - event.vmDefinition().namespace(), event.vmDefinition().name()); - var optVmDef = vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleUser", event.user()); - return status; - }); - if (optVmDef.isEmpty()) { - return; - } - var vmDef = optVmDef.get(); - - // Check if access is possible - if (event.loginUser() - ? !vmDef.conditionStatus("Booted").orElse(false) - : !vmDef.conditionStatus("Running").orElse(false)) { - return; - } - - // Look for secret - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options); - if (stubs.isEmpty()) { - // No secret means no password for this VM wanted - event.setResult(null); - return; - } - var stub = stubs.iterator().next(); - - // Check validity - var secret = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - // Fixed secret, don't touch - event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); - return; - } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) - throws ApiException { - SecureRandom random = null; - try { - random = SecureRandom.getInstanceStrong(); - } catch (NoSuchAlgorithmException e) { // NOPMD - // "Every implementation of the Java platform is required - // to support at least one strong SecureRandom implementation." - } - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - var password = Base64.encode(bytes); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); - - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, - event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, - new CompletionLock(event, 1500)); - pendingPrepares.add(pending); - Event.onCompletion(event, e -> { - pendingPrepares.remove(pending); - }); - - // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); - } - - private boolean stillValid(String expiry) { - if (expiry == null || "never".equals(expiry)) { - return true; - } - @SuppressWarnings({ "PMD.CloseResource", "resource" }) - var scanner = new Scanner(expiry); - if (!scanner.hasNextLong()) { - return false; - } - long expTime = scanner.nextLong(); - return expTime > Instant.now().getEpochSecond() + passwordValidity; - } - - /** - * On vm def changed. - * - * @param event the event - * @param channel the channel - */ - @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingPrepares) { - String vmName = event.vmDefinition().name(); - for (var pending : pendingPrepares) { - if (pending.event.vmDefinition().name().equals(vmName) - && event.vmDefinition().displayPasswordSerial() - .map(s -> s >= pending.expectedSerial).orElse(false)) { - pending.lock.remove(); - // pending will be removed from pendingGest by - // waiting thread, see updatePassword - continue; - } - } - } - } - - /** - * The Class PendingGet. - */ - @SuppressWarnings("PMD.DataClass") - private static class PendingGet { - public final PrepareConsole event; - public final long expectedSerial; - public final CompletionLock lock; - - /** - * Instantiates a new pending get. - * - * @param event the event - * @param expectedSerial the expected serial - */ - public PendingGet(PrepareConsole event, long expectedSerial, - CompletionLock lock) { - super(); - this.event = event; - this.expectedSerial = expectedSerial; - this.lock = lock; - } - } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index dcae3a3..a281b8e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,7 +18,9 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; import freemarker.template.TemplateException; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; @@ -26,25 +28,83 @@ import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Scanner; import java.util.logging.Logger; +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.K8sV1SecretStub; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +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; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jose4j.base64url.Base64; /** - * Delegee for reconciling the display secret + * The properties of the display secret do not only depend on the + * VM definition, but also on events that occur during runtime. + * The reconciler for the display secret is therefore a separate + * component. + * + * The reconciler supports the following configuration properties: + * + * * `passwordValidity`: the validity of the random password in seconds. + * Used to calculate the password expiry time in the generated secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) -/* default */ class DisplaySecretReconciler { +public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); + private int passwordValidity = 10; + private final List pendingPrepares + = Collections.synchronizedList(new LinkedList<>()); + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + // for backward compatibility + .or(() -> { + var oldConfig = event + .structured("/Manager/Controller/DisplaySecretMonitor"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "path '/Manager/Controller/DisplaySecretMonitor' " + + "for `passwordValidity`, please update " + + "the configuration."); + } + return oldConfig; + }).ifPresent(c -> { + try { + if (c.containsKey("passwordValidity")) { + passwordValidity = Integer + .parseInt((String) c.get("passwordValidity")); + } + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } /** * Reconcile. If the configuration prevents generating a secret @@ -104,4 +164,160 @@ import org.jose4j.base64url.Base64; K8sV1SecretStub.create(channel.client(), secret); } + /** + * Prepares access to the console for the user from the event. + * Generates a new password and sends it to the runner. + * Requests the VM (via the runner) to login the user if specified + * in the event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.StringInstantiation") + public void onPrepareConsole(PrepareConsole event, VmChannel channel) + throws ApiException { + // Update console user in status + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + var optVmDef = vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty("consoleUser", event.user()); + return status; + }); + if (optVmDef.isEmpty()) { + return; + } + var vmDef = optVmDef.get(); + + // Check if access is possible + if (event.loginUser() + ? !vmDef.conditionStatus("Booted").orElse(false) + : !vmDef.conditionStatus("Running").orElse(false)) { + return; + } + + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return; + } + var stub = stubs.iterator().next(); + + // Check validity + var secret = stub.model().get(); + @SuppressWarnings("PMD.StringInstantiation") + var expiry = Optional.ofNullable(secret.getData() + .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + return; + } + updatePassword(stub, event); + } + + @SuppressWarnings("PMD.StringInstantiation") + private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) + throws ApiException { + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + var model = stub.model().get(); + model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + + // Prepare wait for confirmation (by VM status change) + var pending = new PendingGet(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingPrepares.add(pending); + Event.onCompletion(event, e -> { + pendingPrepares.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(model).getObject(); + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onVmDefChanged(VmDefChanged event, Channel channel) { + synchronized (pendingPrepares) { + String vmName = event.vmDefinition().name(); + for (var pending : pendingPrepares) { + if (pending.event.vmDefinition().name().equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + @SuppressWarnings("PMD.DataClass") + private static class PendingGet { + public final PrepareConsole event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingGet(PrepareConsole event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 7dbb410..7969d46 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate; * properties to be used by the runners managed by the controller. * This property is a string that holds the content of * a logging.properties file. + * + * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals" }) @@ -163,6 +165,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -177,7 +180,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = new DisplaySecretReconciler(); + dsReconciler = attach(new DisplaySecretReconciler()); stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 77cacad..2c4253e 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -7,16 +7,24 @@ layout: vm-operator ## To version 4.0.0 -The VmViewer conlet has been renamed to VmAccess. This affects the -[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path -"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer" -is still accepted for backward compatibility, but should be updated. + * The VmViewer conlet has been renamed to VmAccess. This affects the + [configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration + information using the old path + `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer` + is still accepted for backward compatibility until the next major version, + but should be updated. -The change of name also causes conlets added to the overview page by -users to "disappear" from the GUI. They have to be re-added. + The change of name also causes conlets added to the overview page by + users to "disappear" from the GUI. They have to be re-added. -The latter behavior also applies to the VmConlet conlet which has been -renamed to VmMgmt. + The latter behavior also applies to the VmConlet conlet which has been + renamed to VmMgmt. + + * The configuration property `passwordValidity` has been moved from component + `/Manager/Controller/DisplaySecretMonitor` to + `/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is + still accepted for backward compatibility until the next major version, + but should be updated. ## To version 3.4.0 diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 0439db2..bc0b93e 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -127,16 +127,20 @@ of 16 (strong) random bytes (128 random bits). It is valid for 10 seconds only. This may be challenging on a slower computer or if users may not enable automatic open for connection files in the browser. The validity can therefore be adjusted in the -configuration. +configuration.[^oldPath] ```yaml "/Manager": "/Controller": - "/DisplaySecretMonitor": - # Validity of generated password in seconds - passwordValidity: 10 + "/Reconciler": + "/DisplaySecretReconciler": + # Validity of generated password in seconds + passwordValidity: 10 ``` +[^oldPath]: Before version 4.0, the path for `passwordValidity` was + `/Manager/Controller/DisplaySecretMonitor`. + Taking into account that the controller generates a display secret automatically by default, this approach to securing console access should be sufficient in all cases. (Any feedback From 0828d0383520b88a019a433e813010e2bee4fc46 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Feb 2025 21:27:39 +0100 Subject: [PATCH 04/29] Javadoc fixes. --- .../org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index afe3d26..fba975e 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -77,7 +77,6 @@ public class GuestAgentClient extends Component { * Instantiates a new guest agent client. * * @param componentChannel the component channel - * @param guestAgentCmds * @throws IOException Signals that an I/O exception has occurred. */ @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", @@ -93,8 +92,7 @@ public class GuestAgentClient extends Component { * forwarded from the {@link Runner} instead. * * @param socketPath the socket path - * @param guestAgentCmds - * @param powerdownTimeout + * @param guestAgentCmds the guest agent cmds */ @SuppressWarnings("PMD.EmptyCatchBlock") /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { From 3012da3e876e1156db5f854a21ac4ebce8d00f8c Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 11:14:46 +0100 Subject: [PATCH 05/29] Add login information to display secret. --- .../jdrupes/vmoperator/manager/Constants.java | 7 ++ .../manager/DisplaySecretReconciler.java | 96 ++++++++++++------- .../vmoperator/manager/Reconciler.java | 2 +- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index 7de839b..f12b512 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.manager; +// TODO: Auto-generated Javadoc /** * Some constants. */ @@ -33,6 +34,12 @@ public class Constants extends org.jdrupes.vmoperator.common.Constants { /** The Constant DATA_PASSWORD_EXPIRY. */ public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + /** The Constant DATA_DISPLAY_USER. */ + public static final String DATA_DISPLAY_USER = "display-user"; + + /** The Constant DATA_DISPLAY_LOGIN. */ + public static final String DATA_DISPLAY_LOGIN = "login-user"; + /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index a281b8e..66bb021 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -26,6 +26,7 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import static java.nio.charset.StandardCharsets.UTF_8; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; @@ -33,16 +34,19 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Scanner; import java.util.logging.Logger; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; 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.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_LOGIN; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_USER; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; @@ -75,6 +79,15 @@ public class DisplaySecretReconciler extends Component { private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); + /** + * Instantiates a new display secret reconciler. + * + * @param componentChannel the component channel + */ + public DisplaySecretReconciler(Channel componentChannel) { + super(componentChannel); + } + /** * On configuration update. * @@ -213,39 +226,13 @@ public class DisplaySecretReconciler extends Component { } var stub = stubs.iterator().next(); - // Check validity + // Get secret and update var secret = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - // Fixed secret, don't touch - event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + var updPw = updatePassword(secret, event); + var updUsr = updateUser(secret, event); + if (!updPw && !updUsr) { return; } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) - throws ApiException { - SecureRandom random = null; - try { - random = SecureRandom.getInstanceStrong(); - } catch (NoSuchAlgorithmException e) { // NOPMD - // "Every implementation of the Java platform is required - // to support at least one strong SecureRandom implementation." - } - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - var password = Base64.encode(bytes); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); // Prepare wait for confirmation (by VM status change) var pending = new PendingGet(event, @@ -257,7 +244,52 @@ public class DisplaySecretReconciler extends Component { }); // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); + stub.update(secret).getObject(); + } + + private boolean updateUser(V1Secret secret, PrepareConsole event) { + var curUser = DataPath. get(secret, "data", DATA_DISPLAY_USER) + .map(b -> new String(b, UTF_8)).orElse(null); + var curLogin = DataPath. get(secret, "data", DATA_DISPLAY_LOGIN) + .map(b -> new String(b, UTF_8)).map(Boolean::parseBoolean) + .orElse(null); + if (Objects.equals(curUser, event.user()) && Objects.equals( + curLogin, event.loginUser())) { + return false; + } + secret.getData().put(DATA_DISPLAY_USER, event.user().getBytes(UTF_8)); + secret.getData().put(DATA_DISPLAY_LOGIN, + Boolean.toString(event.loginUser()).getBytes(UTF_8)); + return true; + } + + private boolean updatePassword(V1Secret secret, PrepareConsole event) { + var expiry = Optional.ofNullable(secret.getData() + .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + return false; + } + + // Generate password and set expiry + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + return true; } private boolean stillValid(String expiry) { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 7969d46..8011e2c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -180,7 +180,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = attach(new DisplaySecretReconciler()); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); From e3b5f5a04dcd92cfbf1b34af605cf6f77534c085 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 11:58:13 +0100 Subject: [PATCH 06/29] Refactor QEMU socket connection handling and start vmop agent. --- dev-example/test-vm.tpl.yaml | 2 +- .../runner/qemu/AgentConnector.java | 86 +++++++ .../vmoperator/runner/qemu/Configuration.java | 6 +- .../runner/qemu/GuestAgentClient.java | 200 ++------------- .../vmoperator/runner/qemu/QemuConnector.java | 234 ++++++++++++++++++ .../vmoperator/runner/qemu/QemuMonitor.java | 134 ++-------- .../vmoperator/runner/qemu/Runner.java | 42 +++- .../runner/qemu/VmopAgentClient.java | 48 ++++ .../templates/Standard-VM-latest.ftl.yaml | 11 +- webpages/vm-operator/upgrading.md | 7 + 10 files changed, 451 insertions(+), 319 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 50031bb..260341e 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -14,7 +14,7 @@ spec: # repository: ghcr.io # path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine # version: "3.0.0" - source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:feature-auto-login pullPolicy: Always permissions: diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java new file mode 100644 index 0000000..40db84a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -0,0 +1,86 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 java.io.IOException; +import java.nio.file.Path; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A component that handles the communication with an agent + * running in the VM. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class AgentConnector extends QemuConnector { + + protected String channelId; + + /** + * Instantiates a new agent connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public AgentConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param channelId the channel id + * @param socketPath the socket path + */ + /* default */ void configure(String channelId, Path socketPath) { + super.configure(socketPath); + this.channelId = channelId; + logger.fine(() -> getClass().getSimpleName() + " configured with" + + " channelId=" + channelId); + } + + /** + * When the virtual serial port with the configured channel id has + * been opened call {@link #agentConnected()}. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if (event.id().equals(channelId) && event.isOpen()) { + agentConnected(); + } + } + + /** + * Called when the agent in the VM opens the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentConnected() { + // Default is to do nothing. + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index 086f085..20d4c66 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -39,7 +39,7 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" }) public class Configuration implements Dto { private static final String CI_INSTANCE_ID = "instance-id"; @@ -67,9 +67,6 @@ public class Configuration implements Dto { /** The monitor socket. */ public Path monitorSocket; - /** The guest agent socket socket. */ - public Path guestAgentSocket; - /** The firmware rom. */ public Path firmwareRom; @@ -344,7 +341,6 @@ public class Configuration implements Dto { runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); monitorSocket = runtimeDir.resolve("monitor.sock"); - guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0"); } if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) { logger.severe(() -> String.format( diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index fba975e..2e5e059 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -19,58 +19,26 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.LinkedList; -import java.util.List; -import java.util.Map; import java.util.Queue; import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; -import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; -import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; -import org.jgrapes.util.events.ConfigurationUpdate; /** - * A component that handles the communication over the guest agent - * socket. + * A component that handles the communication with the guest agent. * * If the log level for this class is set to fine, the messages * exchanged on the monitor socket are logged. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class GuestAgentClient extends Component { +public class GuestAgentClient extends AgentConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; - private List> guestAgentCmds; - private String guestAgentCmd; - private SocketIOChannel gaChannel; private final Queue executing = new LinkedList<>(); /** @@ -79,135 +47,36 @@ public class GuestAgentClient extends Component { * @param componentChannel the component channel * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) public GuestAgentClient(Channel componentChannel) throws IOException { super(componentChannel); } /** - * As the initial configuration of this component depends on the - * configuration of the {@link Runner}, it doesn't have a handler - * for the {@link ConfigurationUpdate} event. The values are - * forwarded from the {@link Runner} instead. - * - * @param socketPath the socket path - * @param guestAgentCmds the guest agent cmds + * When the agent has connected, request the OS information. */ - @SuppressWarnings("PMD.EmptyCatchBlock") - /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { - this.socketPath = socketPath; - try { - this.guestAgentCmds = mapper.convertValue(guestAgentCmds, - mapper.constructType(getClass() - .getDeclaredField("guestAgentCmds").getGenericType())); - } catch (IllegalArgumentException | NoSuchFieldException - | SecurityException e) { - // Cannot happen - } + @Override + protected void agentConnected() { + fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); } /** - * Handle the start event. + * Process agent input. * - * @param event the event + * @param line the line * @throws IOException Signals that an I/O exception has occurred. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - } - - /** - * When the virtual serial port "channel0" has been opened, - * establish the connection by opening the socket. - * - * @param event the event - */ - @Handler - public void onVserportChanged(VserportChangeEvent event) { - if ("channel0".equals(event.id()) && event.isOpen()) { - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(GuestAgentClient.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(GuestAgentClient.class).ifPresent(qm -> { - gaChannel = channel; - channel.setAssociated(GuestAgentClient.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processGuestAgentInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(GuestAgentClient.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(GuestAgentClient.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processGuestAgentInput(String line) - throws IOException { + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "guest agent(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine( - () -> String.format("(Previous \"guest agent(in)\" is " - + "result from executing %s)", executed)); + logger.fine(() -> String.format("(Previous \"guest agent(in)\"" + + " is result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { - processOsInfo(response); + var osInfo = new OsinfoEvent(response.get("return")); + rep().fire(osInfo); } } } catch (JsonProcessingException e) { @@ -215,48 +84,17 @@ public class GuestAgentClient extends Component { } } - private void processOsInfo(ObjectNode response) { - var osInfo = new OsinfoEvent(response.get("return")); - var osId = osInfo.osinfo().get("id").asText(); - for (var cmdDef : guestAgentCmds) { - if (osId.equals(cmdDef.get("osId")) - || "*".equals(cmdDef.get("osId"))) { - guestAgentCmd = cmdDef.get("executable"); - break; - } - } - if (guestAgentCmd == null) { - logger.warning(() -> "No guest agent command for OS " + osId); - } else { - logger.fine(() -> "Guest agent command for OS " + osId - + " is " + guestAgentCmd); - } - rep.fire(osInfo); - } - - /** - * On closed. - * - * @param event the event - */ - @Handler - @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", - "PMD.AvoidDuplicateLiterals" }) - public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - gaChannel = null; - }); - } - /** * On guest agent command. * * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidSynchronizedStatement" }) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onGuestAgentCommand(GuestAgentCommand event) { + if (qemuChannel() == null) { + return; + } var command = event.command(); logger.fine(() -> "guest agent(out): " + command.toString()); String asText; @@ -268,7 +106,7 @@ public class GuestAgentClient extends Component { return; } synchronized (executing) { - gaChannel.associated(Writer.class).ifPresent(writer -> { + writer().ifPresent(writer -> { try { executing.add(command); writer.append(asText).append('\n').flush(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java new file mode 100644 index 0000000..143cfc2 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -0,0 +1,234 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * A component that handles the communication with QEMU over a socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class QemuConnector extends Component { + + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel qemuChannel; + + /** + * Instantiates a new QEMU connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public QemuConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + logger.fine(() -> getClass().getSimpleName() + + " configured with socketPath=" + socketPath); + } + + /** + * Note the runner's event processor and delete the socket. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + fire(new WatchFile(socketPath)); + } + + /** + * Return the runner's event pipeline. + * + * @return the event pipeline + */ + protected EventPipeline rep() { + return rep; + } + + /** + * Watch for the creation of the swtpm socket and start the + * qemu process if it has been created. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.change() == FileChanged.Kind.CREATED + && event.path().equals(socketPath)) { + // qemu running, open socket + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(getClass(), this)); + } + } + + /** + * Check if this is from opening the agent socket and if true, + * save the socket in the context and associate the channel with + * the context. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(getClass()).ifPresent(qm -> { + qemuChannel = channel; + channel.setAssociated(getClass(), this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + processInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + socketConnected(); + }); + } + + /** + * Return the QEMU channel if the connection has been established. + * + * @return the socket IO channel + */ + protected Optional qemuChannel() { + return Optional.ofNullable(qemuChannel); + } + + /** + * Return the {@link Writer} for the connection if the connection + * has been established. + * + * @return the optional + */ + protected Optional writer() { + return qemuChannel().flatMap(c -> c.associated(Writer.class)); + } + + /** + * Called when the connector has been connected to the socket. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void socketConnected() { + // Default is to do nothing. + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onConnectError(ConnectError event, SocketIOChannel channel) { + event.event().associated(getClass()).ifPresent(qm -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from the socket connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(getClass()).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + protected abstract void processInput(String line) throws IOException; + + /** + * On closed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(getClass()).ifPresent(qm -> { + qemuChannel = null; + }); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 7cac734..000a3bf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -19,13 +19,8 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; @@ -42,24 +37,13 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.Components; import org.jgrapes.core.Components.Timer; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.FileChanged; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -69,14 +53,9 @@ import org.jgrapes.util.events.WatchFile; * exchanged on the monitor socket are logged. */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; private int powerdownTimeout; - private SocketIOChannel monitorChannel; private final Queue executing = new LinkedList<>(); private Instant powerdownStartedAt; private Stop suspendedStop; @@ -84,7 +63,7 @@ public class QemuMonitor extends Component { private boolean powerdownConfirmed; /** - * Instantiates a new qemu monitor. + * Instantiates a new QEMU monitor. * * @param componentChannel the component channel * @param configDir the config dir @@ -111,109 +90,26 @@ public class QemuMonitor extends Component { * @param powerdownTimeout */ /* default */ void configure(Path socketPath, int powerdownTimeout) { - this.socketPath = socketPath; + super.configure(socketPath); this.powerdownTimeout = powerdownTimeout; } /** - * Handle the start event. - * - * @param event the event - * @throws IOException Signals that an I/O exception has occurred. + * When the socket is connected, send the capabilities command. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - fire(new WatchFile(socketPath)); + @Override + protected void socketConnected() { + fire(new MonitorCommand(new QmpCapabilities())); } - /** - * Watch for the creation of the swtpm socket and start the - * qemu process if it has been created. - * - * @param event the event - */ - @Handler - public void onFileChanged(FileChanged event) { - if (event.change() == FileChanged.Kind.CREATED - && event.path().equals(socketPath)) { - // qemu running, open socket - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(QemuMonitor.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = channel; - channel.setAssociated(QemuMonitor.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processMonitorInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new MonitorCommand(new QmpCapabilities())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(QemuMonitor.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(QemuMonitor.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processMonitorInput(String line) + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - rep.fire(new MonitorReady()); + rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { @@ -221,11 +117,11 @@ public class QemuMonitor extends Component { logger.fine( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep.fire(MonitorResult.from(executed, response)); + rep().fire(MonitorResult.from(executed, response)); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep::fire); + MonitorEvent.from(response).ifPresent(rep()::fire); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -241,8 +137,8 @@ public class QemuMonitor extends Component { @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.AvoidDuplicateLiterals" }) public void onClosed(Closed event, SocketIOChannel channel) { + super.onClosed(event, channel); channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; synchronized (this) { if (powerdownTimer != null) { powerdownTimer.cancel(); @@ -275,7 +171,7 @@ public class QemuMonitor extends Component { return; } synchronized (executing) { - monitorChannel.associated(Writer.class).ifPresent(writer -> { + writer().ifPresent(writer -> { try { executing.add(command); writer.append(asText).append('\n').flush(); @@ -295,7 +191,7 @@ public class QemuMonitor extends Component { @Handler(priority = 100) @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (monitorChannel != null) { + if (qemuChannel() != null) { // We have a connection to Qemu, attempt ACPI shutdown. event.suspendHandling(); suspendedStop = event; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index e0cd837..0eaabe9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; @@ -198,7 +197,6 @@ public class Runner extends Component { private static final String QEMU = "qemu"; private static final String SWTPM = "swtpm"; private static final String CLOUD_INIT_IMG = "cloudInitImg"; - private static final String GUEST_AGENT_CMDS = "guestAgentCmds"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -222,6 +220,7 @@ public class Runner extends Component { private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; private final GuestAgentClient guestAgentClient; + private final VmopAgentClient vmopAgentClient; private Integer resetCounter; private RunState state = RunState.INITIALIZING; @@ -280,6 +279,7 @@ public class Runner extends Component { attach(new SocketConnector(channel())); attach(qemuMonitor = new QemuMonitor(channel(), configDir)); attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); @@ -350,16 +350,12 @@ public class Runner extends Component { .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); - var guestAgentCmds = (ArrayNode) tplData.get(GUEST_AGENT_CMDS); - if (guestAgentCmds != null) { - logger.finest( - () -> "GuestAgentCmds: " + guestAgentCmds.toString()); - } // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); - guestAgentClient.configure(config.guestAgentSocket, guestAgentCmds); + configureAgentClient(guestAgentClient, "guest-agent-socket"); + configureAgentClient(vmopAgentClient, "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); @@ -484,6 +480,36 @@ public class Runner extends Component { } } + @SuppressWarnings("PMD.CognitiveComplexity") + private void configureAgentClient(AgentConnector client, String chardev) { + String id = null; + Path path = null; + for (var arg : qemuDefinition.command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + id = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + path = Path.of(prop.substring(5)); + } + } + } + } + if (id == null || path == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } + client.configure(id, path); + } + /** * Handle the started event. * diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java new file mode 100644 index 0000000..a74432b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -0,0 +1,48 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 java.io.IOException; +import org.jgrapes.core.Channel; + +/** + * A component that handles the communication over the vmop agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public class VmopAgentClient extends AgentConnector { + + /** + * Instantiates a new VM operator agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public VmopAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + @Override + protected void processInput(String line) throws IOException { + // TODO Auto-generated method stub + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index 3eacfa3..c5c0252 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -122,11 +122,16 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection. MUST have id "channel0"! + # - Guest agent serial connection. - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ] + # - VM operator agent serial connection. + - [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\ + chardev=vmop-agent-socket" ] + - [ "-chardev","socket,id=vmop-agent-socket,\ + path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ] # * USB Hub and devices (more in SPICE configuration below) # https://qemu-project.gitlab.io/qemu/system/devices/usb.html # https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c @@ -233,7 +238,3 @@ - -"guestAgentCmds": - - "osId": "*" - "executable": "/usr/local/libexec/vm-operator-cmd" diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 2c4253e..422c32d 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -26,6 +26,13 @@ layout: vm-operator still accepted for backward compatibility until the next major version, but should be updated. + * The standard [template](./runner.html#stand-alone-configuration) used + to generate the QEMU command has been updated. Unless you have enabled + automatic updates of the template in the VM definition, you have to + update the template manually. If you're using your own template, you + have to add a virtual serial port (see the git history of the standard + template for the required addition). + ## To version 3.4.0 Starting with this version, the VM-Operator no longer uses a stateful set From c45c452c83a09cf6c00ab6ab3cfe8ad0ab7e6529 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 13:20:28 +0100 Subject: [PATCH 07/29] Adjust class name. --- .../vmoperator/manager/DisplaySecretReconciler.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 66bb021..e1955b4 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -76,7 +76,7 @@ public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); private int passwordValidity = 10; - private final List pendingPrepares + private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); /** @@ -234,8 +234,8 @@ public class DisplaySecretReconciler extends Component { return; } - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, + // Register wait for confirmation (by VM status change) + var pending = new PendingPrepare(event, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, new CompletionLock(event, 1500)); pendingPrepares.add(pending); @@ -333,7 +333,7 @@ public class DisplaySecretReconciler extends Component { * The Class PendingGet. */ @SuppressWarnings("PMD.DataClass") - private static class PendingGet { + private static class PendingPrepare { public final PrepareConsole event; public final long expectedSerial; public final CompletionLock lock; @@ -344,7 +344,7 @@ public class DisplaySecretReconciler extends Component { * @param event the event * @param expectedSerial the expected serial */ - public PendingGet(PrepareConsole event, long expectedSerial, + public PendingPrepare(PrepareConsole event, long expectedSerial, CompletionLock lock) { super(); this.event = event; From bc33640c98f75b23adf5e0ea12c0a949c2a7de2d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 18:09:14 +0100 Subject: [PATCH 08/29] Avoid duplicate constants. --- .../jdrupes/vmoperator/common/Constants.java | 19 ++++++++++++++++--- .../jdrupes/vmoperator/manager/Constants.java | 16 ---------------- .../runner/qemu/DisplayController.java | 12 ++++++------ 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 5837264..9bfba8d 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -27,9 +27,6 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; - /** The Constant COMP_DISPLAY_SECRETS. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; @@ -41,4 +38,20 @@ public class Constants { /** The Constant VM_OP_KIND_VM_POOL. */ public static final String VM_OP_KIND_VM_POOL = "VmPool"; + + /** The Constant COMP_DISPLAY_SECRETS. */ + public static final String COMP_DISPLAY_SECRET = "display-secret"; + + /** The Constant DATA_DISPLAY_PASSWORD. */ + public static final String DATA_DISPLAY_PASSWORD = "display-password"; + + /** The Constant DATA_PASSWORD_EXPIRY. */ + public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + + /** The Constant DATA_DISPLAY_USER. */ + public static final String DATA_DISPLAY_USER = "display-user"; + + /** The Constant DATA_DISPLAY_LOGIN. */ + public static final String DATA_DISPLAY_LOGIN = "login-user"; + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index f12b512..c5c8528 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -18,28 +18,12 @@ package org.jdrupes.vmoperator.manager; -// TODO: Auto-generated Javadoc /** * Some constants. */ @SuppressWarnings("PMD.DataClass") public class Constants extends org.jdrupes.vmoperator.common.Constants { - /** The Constant COMP_DISPLAY_SECRET. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - - /** The Constant DATA_DISPLAY_PASSWORD. */ - public static final String DATA_DISPLAY_PASSWORD = "display-password"; - - /** The Constant DATA_PASSWORD_EXPIRY. */ - public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; - - /** The Constant DATA_DISPLAY_USER. */ - public static final String DATA_DISPLAY_USER = "display-user"; - - /** The Constant DATA_DISPLAY_LOGIN. */ - public static final String DATA_DISPLAY_LOGIN = "login-user"; - /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 1f9833c..fab11b1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -23,6 +23,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -40,8 +42,6 @@ import org.jgrapes.util.events.WatchFile; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class DisplayController extends Component { - public static final String DISPLAY_PASSWORD_FILE = "display-password"; - public static final String PASSWORD_EXPIRY_FILE = "password-expiry"; private String currentPassword; private String protocol; private final Path configDir; @@ -57,7 +57,7 @@ public class DisplayController extends Component { public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE))); + fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD))); } /** @@ -83,7 +83,7 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) { + if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) { updatePassword(); } } @@ -100,7 +100,7 @@ public class DisplayController extends Component { private boolean setDisplayPassword() { String password; - Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE); + Path dpPath = configDir.resolve(DATA_DISPLAY_PASSWORD); if (dpPath.toFile().canRead()) { logger.finer(() -> "Found display password"); try { @@ -125,7 +125,7 @@ public class DisplayController extends Component { } private void setPasswordExpiry() { - Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE); + Path pePath = configDir.resolve(DATA_PASSWORD_EXPIRY); if (!pePath.toFile().canRead()) { return; } From c6704c886f653a9a47bcafc04840afbf25157fe0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 18:18:29 +0100 Subject: [PATCH 09/29] Avoid duplicate constants. --- .../src/org/jdrupes/vmoperator/runner/qemu/Runner.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 0eaabe9..f64af2d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -56,6 +56,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -311,8 +312,7 @@ public class Runner extends Component { // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath - = configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE); + Path dsPath = configDir.resolve(DATA_DISPLAY_PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) From d1bc335db908f189c98b1be79f809dc849652534 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 21:21:58 +0100 Subject: [PATCH 10/29] Prepare auto login. --- .../runner/qemu/DisplayController.java | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index fab11b1..7dee0a2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -22,8 +22,11 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; +import java.util.Optional; import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_LOGIN; import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_USER; import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; @@ -99,47 +102,41 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - String password; - Path dpPath = configDir.resolve(DATA_DISPLAY_PASSWORD); - if (dpPath.toFile().canRead()) { - logger.finer(() -> "Found display password"); - try { - password = Files.readString(dpPath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read display" - + " password: " + e.getMessage()); - return false; + return readFromFile(DATA_DISPLAY_PASSWORD).map(password -> { + if (Objects.equals(this.currentPassword, password)) { + return true; } - } else { - logger.finer(() -> "No display password"); - return false; - } - - if (Objects.equals(this.currentPassword, password)) { + this.currentPassword = password; + logger.fine(() -> "Updating display password"); + fire(new MonitorCommand( + new QmpSetDisplayPassword(protocol, password))); return true; - } - this.currentPassword = password; - logger.fine(() -> "Updating display password"); - fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password))); - return true; + }).orElse(false); } private void setPasswordExpiry() { - Path pePath = configDir.resolve(DATA_PASSWORD_EXPIRY); - if (!pePath.toFile().canRead()) { - return; - } - logger.finer(() -> "Found expiry time"); - String expiry; - try { - expiry = Files.readString(pePath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read expiry" - + " time: " + e.getMessage()); - return; - } - logger.fine(() -> "Updating expiry time"); - fire(new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + readFromFile(DATA_PASSWORD_EXPIRY).ifPresent(expiry -> { + logger.fine(() -> "Updating expiry time to " + expiry); + fire( + new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + }); } + private Optional readFromFile(String dataItem) { + Path path = configDir.resolve(dataItem); + String label = dataItem.replace('-', ' '); + if (path.toFile().canRead()) { + logger.finer(() -> "Found display user"); + try { + return Optional.ofNullable(Files.readString(path)); + } catch (IOException e) { + logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": " + + e.getMessage()); + return Optional.empty(); + } + } else { + logger.finer(() -> "No " + label); + return Optional.empty(); + } + } } From 2119c215fca384c653413c5dd163843f2f48e962 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 25 Feb 2025 10:44:56 +0100 Subject: [PATCH 11/29] Prevent publishing doc in branches (except main). --- build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index df173d8..8a7b571 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } plugins { - id 'org.ajoberstar.grgit' version '5.2.0' apply false + id 'org.ajoberstar.grgit' version '5.2.0' id 'org.ajoberstar.git-publish' version '4.2.0' apply false id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false id 'org.jdrupes.vmoperator.versioning-conventions' @@ -28,7 +28,9 @@ task stage { tc -> tc.findByName("build") }.flatten() } - if (JavaVersion.current() == JavaVersion.VERSION_21) { + def gitBranch = grgit.branch.current.name.replace('/', '-') + if (JavaVersion.current() == JavaVersion.VERSION_21 + && gitBranch == "main") { // Publish JavaDoc dependsOn gitPublishPush } From d2c39dc06a95094ccd6a4cf4453e41f1b0433070 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 25 Feb 2025 13:48:23 +0100 Subject: [PATCH 12/29] Rename. --- dev-example/{gen-pool-vm-crds.sh => gen-pool-vm-crds} | 0 dev-example/{pool-action.sh => pool-action} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename dev-example/{gen-pool-vm-crds.sh => gen-pool-vm-crds} (100%) rename dev-example/{pool-action.sh => pool-action} (100%) diff --git a/dev-example/gen-pool-vm-crds.sh b/dev-example/gen-pool-vm-crds similarity index 100% rename from dev-example/gen-pool-vm-crds.sh rename to dev-example/gen-pool-vm-crds diff --git a/dev-example/pool-action.sh b/dev-example/pool-action similarity index 100% rename from dev-example/pool-action.sh rename to dev-example/pool-action From 4a7a309f071416074240a51184fa8820acf22234 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 25 Feb 2025 15:43:47 +0100 Subject: [PATCH 13/29] Get started with vmop-agent. --- dev-example/vmop-agent/99-vmop-agent.rules | 2 ++ dev-example/vmop-agent/vmop-agent | 19 +++++++++++ dev-example/vmop-agent/vmop-agent.service | 15 ++++++++ .../vmoperator/runner/qemu/StatusUpdater.java | 34 +++++++++++++++---- .../runner/qemu/VmopAgentClient.java | 5 ++- .../qemu/events/VmopAgentConnected.java | 27 +++++++++++++++ 6 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 dev-example/vmop-agent/99-vmop-agent.rules create mode 100755 dev-example/vmop-agent/vmop-agent create mode 100644 dev-example/vmop-agent/vmop-agent.service create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java diff --git a/dev-example/vmop-agent/99-vmop-agent.rules b/dev-example/vmop-agent/99-vmop-agent.rules new file mode 100644 index 0000000..4a18472 --- /dev/null +++ b/dev-example/vmop-agent/99-vmop-agent.rules @@ -0,0 +1,2 @@ +SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \ + TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service" diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent new file mode 100755 index 0000000..b3157b9 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent @@ -0,0 +1,19 @@ +#!/usr/bin/bash + +hostSerial="/dev/virtio-ports/org.jdrupes.vmop_agent.0" + +if [ ! -w "$hostSerial" ]; then + echo >&2 "Device $hostSerial not writable" + exit 1 +fi + +if ! exec {con}<>"$hostSerial"; then + echo >&2 "Cannot open device $hostSerial" + exit 1 +fi + +echo >&${con} "220 Hello" + +while read line <&${con}; do + true +done diff --git a/dev-example/vmop-agent/vmop-agent.service b/dev-example/vmop-agent/vmop-agent.service new file mode 100644 index 0000000..11c64f2 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent.service @@ -0,0 +1,15 @@ +[Unit] +Description=VM-Operator (Guest) Agent +BindsTo=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device +After=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device multi-user.target +IgnoreOnIsolate=True + +[Service] +UMask=0077 +#EnvironmentFile=/etc/sysconfig/vmop-agent +ExecStart=/usr/local/libexec/vmop-agent +Restart=always +RestartSec=0 + +[Install] +WantedBy=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device 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 f9644c8..fa0e3ab 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 @@ -47,6 +47,7 @@ import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; 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.VmopAgentConnected; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.annotation.Handler; @@ -192,20 +193,21 @@ public class StatusUpdater extends VmDefUpdater { status.addProperty("ram", GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); status.addProperty("cpus", 1); - - // In case we had an irregular shutdown - status.remove("osinfo"); } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); - status.remove("osinfo"); } - // In case console connection was still present if (!running) { + // In case console connection was still present status.addProperty("consoleClient", ""); updateCondition(from, status, "ConsoleConnected", false, - "VmStopped", "The VM has been shut down"); + "VmStopped", "The VM is not running"); + + // In case we had an irregular shutdown + status.remove("osinfo"); + updateCondition(vmDef, vmDef.statusJson(), "VmopAgentConnected", + false, "VmStopped", "The VM is not running"); } return status; }); @@ -322,4 +324,24 @@ public class StatusUpdater extends VmDefUpdater { }); } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentConnected(VmopAgentConnected event) + throws ApiException { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + updateCondition(vmDef, status, "VmopAgentConnected", + true, "VmopAgentStarted", "The VM operator agent is running"); + return status; + }); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java index a74432b..89fdaa2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -19,6 +19,7 @@ package org.jdrupes.vmoperator.runner.qemu; import java.io.IOException; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; import org.jgrapes.core.Channel; /** @@ -42,7 +43,9 @@ public class VmopAgentClient extends AgentConnector { @Override protected void processInput(String line) throws IOException { - // TODO Auto-generated method stub + if (line.startsWith("220 ")) { + rep().fire(new VmopAgentConnected()); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java new file mode 100644 index 0000000..dc13569 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class VmopAgentConnected extends Event { +} From a1e941276ec9e89931e2b8b8048e0c4cc8183361 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 26 Feb 2025 21:59:38 +0100 Subject: [PATCH 14/29] Working login script. --- dev-example/vmop-agent/vmop-agent | 103 ++++++++++++++++++++++++++++-- webpages/vm-operator/pools.md | 65 +++++++++++++++++++ 2 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 webpages/vm-operator/pools.md diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index b3157b9..ad5f427 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -1,19 +1,110 @@ #!/usr/bin/bash -hostSerial="/dev/virtio-ports/org.jdrupes.vmop_agent.0" +while [ "$#" -gt 0 ]; do + case "$1" in + --path) shift; ttyPath="$1";; + --path=*) IFS='=' read -r option value <<< "$1"; ttyPath="$value";; + esac + shift +done -if [ ! -w "$hostSerial" ]; then - echo >&2 "Device $hostSerial not writable" +ttyPath="${ttyPath:-/dev/virtio-ports/org.jdrupes.vmop_agent.0}" + +if [ ! -w "$ttyPath" ]; then + echo >&2 "Device $ttyPath not writable" exit 1 fi -if ! exec {con}<>"$hostSerial"; then - echo >&2 "Cannot open device $hostSerial" +if ! exec {con}<>"$ttyPath"; then + echo >&2 "Cannot open device $ttyPath" exit 1 fi +temperr=$(mktemp) +clear >/dev/tty1 echo >&${con} "220 Hello" +createUser() { + local missing=$1 + local uid + local userHome="/home/$missing" + local createOpts="" + if [ -d "$userHome" ]; then + uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $3}') + createOpts="--no-create-home" + else + uid=$(ls -ln "/home" | tail -n +2 | awk '{print $3}' | sort | tail -1) + uid=$(( $uid + 1 )) + if [ $uid -lt 1000 ]; then + uid=1000 + fi + createOpts="--create-home" + fi + groupadd -g $uid $missing + useradd $missing -u $uid -g $uid $createOpts +} + +doLogin() { + user=$1 + if [ "$user" = "root" ]; then + echo >&${con} "504 Won't log in root" + return + fi + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + ( flock 200 + createUser ${user} + ) 200>/home/.gen-uid-lock + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + echo >&${con} "451 Cannot determine uid" + return + fi + fi + systemd-run 2>$temperr \ + --unit vmop-user-desktop --uid=$uid --gid=$uid \ + --working-directory="/home/$user" -p TTYPath=/dev/tty1 \ + -p PAMName=login -p StandardInput=tty -p StandardOutput=journal \ + -E XDG_RUNTIME_DIR="/run/user/$uid" \ + -p ExecStartPre="/usr/bin/chvt 1" \ + dbus-run-session -- gnome-shell --display-server --wayland + if [ $? -eq 0 ]; then + echo >&${con} "201 User logged in" + else + echo >&${con} "451 $(<${temperr})" + fi +} + +attemptLogout() { + systemctl status vmop-user-desktop > /dev/null 2>&1 + if [ $? = 0 ]; then + systemctl stop vmop-user-desktop + echo >&${con} "102 Desktop stopped" + fi +} + +doLogout() { + attemptLogout + loginctl -j | jq -r '.[] | select(.tty=="tty1") | .session' \ + | while read sid; do + loginctl kill-session $sid + done + echo >&${con} "202 User logged out" +} + while read line <&${con}; do - true + case $line in + "login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};; + logout) doLogout;; + esac done + +onExit() { + attemptLogout + if [ -n "$temperr" ]; then + rm -f $temperr + fi + echo >&${con} "240 Quit" +} + +trap onExit EXIT diff --git a/webpages/vm-operator/pools.md b/webpages/vm-operator/pools.md new file mode 100644 index 0000000..290d0fd --- /dev/null +++ b/webpages/vm-operator/pools.md @@ -0,0 +1,65 @@ +--- +title: "VM-Operator: VM pools — assigning VMs to users dynamically" +layout: vm-operator +--- + +# VM Pools + +*Since 4.0.0* + +## Prepare the VM + +### Shared file system + +Mount a shared file system as home file system on all VMs in the pool. + +### Restrict access + +The only possibility to access the VMs should be via a desktop started by +the VM-Operator. + + * Disable the display manager. + + ```console + # systemctl disable gdm + # systemctl stop gdm + ``` + + * Disable `getty` on tty1. + + ```console + # systemctl mask getty@tty1 + # systemctl stop getty@tty1 + ``` + + You can, of course, disable `getty` completely. If you do this, make sure + that you can still access your master VM through `ssh`, else you have + locked yourself out. + + * Prevent suspend/hibernate, because it will lock the VM. + + ```console + # systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target + ``` + +### Install the VM-Operator agent + +The VM-Operator agent runs as a systemd service. Sample configuration +files can be found +[here](https://github.com/mnlipp/VM-Operator/tree/main/dev-example/vmop-agent). +Copy + + * `99-vmop-agent.rules` to `/usr/local/lib/udev/rules.d/99-vmop-agent.rules`, + * `vmop-agent` to `/usr/local/libexec/vmop-agent` and + * `vmop-agent.service` to `/usr/local/lib/systemd/system/vmop-agent.service`. + +Note that some of the target directories do not exist by default and have to +be created first. Don't forget to run `restorecon` on systems with SELinux. + +Enable everything: + +```console +# udevadm control --reload-rules +# systemctl enable vmop-agent +# udevadm trigger + ``` From b4bc0c7b0f6ebb67ede504366c6886e323ef6a6f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 26 Feb 2025 22:00:32 +0100 Subject: [PATCH 15/29] Delay enable until VM operator agent started. --- .../vmaccess/browser/VmAccess-functions.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index 31408cb..f0ef3c8 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -74,7 +74,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const busy = computed(() => previewApi.vmDefinition.spec && (previewApi.vmDefinition.spec.vm.state === 'Running' && (previewApi.poolName - ? !previewApi.vmDefinition.booted + ? !previewApi.vmDefinition.vmopAgent : !previewApi.vmDefinition.running) || previewApi.vmDefinition.spec.vm.state === 'Stopped' && previewApi.vmDefinition.running)); @@ -87,7 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); - const booted = computed(() => previewApi.vmDefinition.booted); + const vmopAgent = computed(() => previewApi.vmDefinition.vmopAgent); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.permissions); const osicon = computed(() => { @@ -123,7 +123,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, poolName, vmName, - configured, busy, startable, stoppable, running, booted, + configured, busy, startable, stoppable, running, vmopAgent, inUse, permissions, osicon }; }, template: ` @@ -133,7 +133,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, Date: Wed, 26 Feb 2025 22:33:08 +0100 Subject: [PATCH 16/29] Fix user switching. --- dev-example/vmop-agent/vmop-agent | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index ad5f427..2474e66 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -50,6 +50,12 @@ doLogin() { echo >&${con} "504 Won't log in root" return fi + curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty1") | .user') + if [ "$curUser" = "$user" ]; then + echo >&${con} "201 User already logged in" + return + fi + attemptLogout uid=$(id -u ${user} 2>/dev/null) if [ $? != 0 ]; then ( flock 200 @@ -69,7 +75,7 @@ doLogin() { -p ExecStartPre="/usr/bin/chvt 1" \ dbus-run-session -- gnome-shell --display-server --wayland if [ $? -eq 0 ]; then - echo >&${con} "201 User logged in" + echo >&${con} "201 User logged in successfully" else echo >&${con} "451 $(<${temperr})" fi @@ -79,16 +85,16 @@ attemptLogout() { systemctl status vmop-user-desktop > /dev/null 2>&1 if [ $? = 0 ]; then systemctl stop vmop-user-desktop - echo >&${con} "102 Desktop stopped" fi -} - -doLogout() { - attemptLogout loginctl -j | jq -r '.[] | select(.tty=="tty1") | .session' \ | while read sid; do loginctl kill-session $sid done + echo >&${con} "102 Desktop stopped" +} + +doLogout() { + attemptLogout echo >&${con} "202 User logged out" } From 59bf4937ef53e8f124ed5738f5192b4298299504 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 27 Feb 2025 14:45:25 +0100 Subject: [PATCH 17/29] Avoid multi-line message. --- dev-example/vmop-agent/vmop-agent | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index 2474e66..ab4e765 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -77,7 +77,7 @@ doLogin() { if [ $? -eq 0 ]; then echo >&${con} "201 User logged in successfully" else - echo >&${con} "451 $(<${temperr})" + echo >&${con} "451 $(tr '\n' ' ' <${temperr})" fi } From b4cb3b8694b3bbf09580742ceb20b0cb7602fe20 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 27 Feb 2025 14:46:13 +0100 Subject: [PATCH 18/29] Control login. --- .../runner/qemu/DisplayController.java | 66 +++++++++++++- .../runner/qemu/GuestAgentClient.java | 17 ++-- .../vmoperator/runner/qemu/QemuConnector.java | 20 ++++- .../vmoperator/runner/qemu/QemuMonitor.java | 16 ++-- .../runner/qemu/VmopAgentClient.java | 89 ++++++++++++++++++- .../runner/qemu/events/VmopAgentLogIn.java | 45 ++++++++++ .../runner/qemu/events/VmopAgentLogOut.java | 27 ++++++ .../runner/qemu/events/VmopAgentLoggedIn.java | 49 ++++++++++ .../qemu/events/VmopAgentLoggedOut.java | 49 ++++++++++ 9 files changed, 349 insertions(+), 29 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 7dee0a2..a2a6bc9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -33,6 +33,10 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -48,6 +52,8 @@ public class DisplayController extends Component { private String currentPassword; private String protocol; private final Path configDir; + private boolean vmopAgentConnected; + private boolean userLoginRequested; /** * Instantiates a new Display controller. @@ -63,6 +69,16 @@ public class DisplayController extends Component { fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD))); } + /** + * On vmop agent connected. + * + * @param event the event + */ + @Handler + public void onVmopAgentConnected(VmopAgentConnected event) { + vmopAgentConnected = true; + } + /** * On configure qemu. * @@ -75,7 +91,7 @@ public class DisplayController extends Component { } protocol = event.configuration().vm.display.spice != null ? "spice" : null; - updatePassword(); + configureAccess(false); } /** @@ -87,12 +103,56 @@ public class DisplayController extends Component { @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) { - updatePassword(); + configureAccess(true); } } @SuppressWarnings("PMD.DataflowAnomalyAnalysis") - private void updatePassword() { + private void configureAccess(boolean passwordChange) { + var userLoginConfigured = readFromFile(DATA_DISPLAY_LOGIN) + .map(Boolean::parseBoolean).orElse(false); + if (!userLoginConfigured) { + // Check if it was configured before and there may thus be an + // active auto login + if (userLoginRequested && vmopAgentConnected) { + // Make sure to log out + fire(new VmopAgentLogOut()); + } + userLoginRequested = false; + configurePassword(); + return; + } + + // With user login configured, we have to make sure that the + // user is logged in before we set the password and thus allow + // access to the display. + userLoginRequested = true; + if (!vmopAgentConnected) { + if (passwordChange) { + logger.warning(() -> "Request for user login before " + + "VM operator agent has connected"); + } + return; + } + + var user = readFromFile(DATA_DISPLAY_USER); + if (user.isEmpty()) { + logger.warning(() -> "Login requested, but no user configured"); + } + fire(new VmopAgentLogIn(user.get()).setAssociated(this, user.get())); + } + + /** + * On vmop agent logged in. + * + * @param event the event + */ + @Handler + public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) { + configurePassword(); + } + + private void configurePassword() { if (protocol == null) { return; } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index 2e5e059..880ca58 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -88,10 +88,12 @@ public class GuestAgentClient extends AgentConnector { * On guest agent command. * * @param event the event + * @throws IOException */ @Handler @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onGuestAgentCommand(GuestAgentCommand event) { + public void onGuestAgentCommand(GuestAgentCommand event) + throws IOException { if (qemuChannel() == null) { return; } @@ -106,15 +108,10 @@ public class GuestAgentClient extends AgentConnector { return; } synchronized (executing) { - writer().ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, e::getMessage); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java index 143cfc2..2e94c14 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -47,8 +47,8 @@ import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication with QEMU over a socket. * - * If the log level for this class is set to fine, the messages - * exchanged on the socket are logged. + * Derived classes should log the messages exchanged on the socket + * if the log level is set to fine. */ public abstract class QemuConnector extends Component { @@ -174,6 +174,22 @@ public abstract class QemuConnector extends Component { return qemuChannel().flatMap(c -> c.associated(Writer.class)); } + /** + * Send the given command to QEMU. A newline is appended to the + * command automatically. + * + * @param command the command + * @return true, if successful + * @throws IOException Signals that an I/O exception has occurred. + */ + protected boolean sendCommand(String command) throws IOException { + if (writer().isEmpty()) { + return false; + } + writer().get().append(command).append('\n').flush(); + return true; + } + /** * Called when the connector has been connected to the socket. */ diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 000a3bf..1de8f60 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -155,11 +155,12 @@ public class QemuMonitor extends QemuConnector { * On monitor command. * * @param event the event + * @throws IOException */ @Handler @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidSynchronizedStatement" }) - public void onExecQmpCommand(MonitorCommand event) { + public void onExecQmpCommand(MonitorCommand event) throws IOException { var command = event.command(); logger.fine(() -> "monitor(out): " + command.toString()); String asText; @@ -171,15 +172,10 @@ public class QemuMonitor extends QemuConnector { return; } synchronized (executing) { - writer().ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, e::getMessage); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java index 89fdaa2..f50d397 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -19,8 +19,16 @@ package org.jdrupes.vmoperator.runner.qemu; import java.io.IOException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; /** * A component that handles the communication over the vmop agent @@ -31,6 +39,8 @@ import org.jgrapes.core.Channel; */ public class VmopAgentClient extends AgentConnector { + private final Deque> executing = new ConcurrentLinkedDeque<>(); + /** * Instantiates a new VM operator agent client. * @@ -41,11 +51,82 @@ public class VmopAgentClient extends AgentConnector { super(componentChannel); } - @Override - protected void processInput(String line) throws IOException { - if (line.startsWith("220 ")) { - rep().fire(new VmopAgentConnected()); + /** + * On vmop agent login. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogIn(VmopAgentLogIn event) throws IOException { + logger.fine(() -> "vmop agent(out): login " + event.user()); + if (writer().isPresent()) { + executing.add(event); + sendCommand("login " + event.user()); } } + /** + * On vmop agent logout. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogout(VmopAgentLogOut event) throws IOException { + logger.fine(() -> "vmop agent(out): logout"); + if (writer().isPresent()) { + executing.add(event); + sendCommand("logout"); + } + } + + @Override + @SuppressWarnings({ "PMD.UnnecessaryReturn", + "PMD.AvoidLiteralsInIfCondition" }) + protected void processInput(String line) throws IOException { + logger.fine(() -> "vmop agent(in): " + line); + + // Check validity + if (line.isEmpty() || !Character.isDigit(line.charAt(0))) { + logger.warning(() -> "Illegal response: " + line); + return; + } + + // Check positive responses + if (line.startsWith("220 ")) { + rep().fire(new VmopAgentConnected()); + return; + } + if (line.startsWith("201 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogIn login) { + rep().fire(new VmopAgentLoggedIn(login)); + } else { + logger.severe(() -> "Response " + line + + " does not match executing command " + cmd); + } + return; + } + if (line.startsWith("202 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogOut logout) { + rep().fire(new VmopAgentLoggedOut(logout)); + } else { + logger.severe(() -> "Response " + line + + "does not match executing command " + cmd); + } + return; + } + + // Ignore unhandled continuations + if (line.charAt(0) == '1') { + return; + } + + // Error + logger.warning(() -> "Error response: " + line); + executing.pop(); + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java new file mode 100644 index 0000000..96db884 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java @@ -0,0 +1,45 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 org.jgrapes.core.Event; + +/** + * Sends the login command to the VM operator agent. + */ +public class VmopAgentLogIn extends Event { + + private final String user; + + /** + * Instantiates a new vmop agent logout. + */ + public VmopAgentLogIn(String user) { + this.user = user; + } + + /** + * Returns the user. + * + * @return the user + */ + public String user() { + return user; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java new file mode 100644 index 0000000..1502200 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 org.jgrapes.core.Event; + +/** + * Sends the logout command to the VM operator agent. + */ +public class VmopAgentLogOut extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java new file mode 100644 index 0000000..f59ed71 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedIn extends Event { + + private final VmopAgentLogIn triggering; + + /** + * Instantiates a new vmop agent logged in. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedIn(VmopAgentLogIn triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogIn triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java new file mode 100644 index 0000000..5f60e00 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * 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 org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedOut extends Event { + + private final VmopAgentLogOut triggering; + + /** + * Instantiates a new vmop agent logged out. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedOut(VmopAgentLogOut triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogOut triggering() { + return triggering; + } + +} From 5cbdab9da88fc1e6dfda49cef030f97378332765 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 27 Feb 2025 14:55:31 +0100 Subject: [PATCH 19/29] Fix log message. --- .../org/jdrupes/vmoperator/runner/qemu/DisplayController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index a2a6bc9..6a3f783 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -186,7 +186,7 @@ public class DisplayController extends Component { Path path = configDir.resolve(dataItem); String label = dataItem.replace('-', ' '); if (path.toFile().canRead()) { - logger.finer(() -> "Found display user"); + logger.finer(() -> "Found " + label); try { return Optional.ofNullable(Files.readString(path)); } catch (IOException e) { From 5ec220d0a692022a36a558e1ccd3c12ccc5f7412 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 27 Feb 2025 17:41:57 +0100 Subject: [PATCH 20/29] Use gids for id management and isolate home directories. --- dev-example/vmop-agent/vmop-agent | 43 +++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index ab4e765..99aa25d 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -15,28 +15,45 @@ if [ ! -w "$ttyPath" ]; then exit 1 fi +# Create fd for the tty in variable con if ! exec {con}<>"$ttyPath"; then echo >&2 "Cannot open device $ttyPath" exit 1 fi +# Temporary file for logging error messages, clear tty and signal ready temperr=$(mktemp) clear >/dev/tty1 echo >&${con} "220 Hello" +# This script uses the (shared) home directory as "dictonary" for +# synchronizing the username and the uid between hosts. +# +# Every user has a directory with his username. The directory is +# owned by root to prevent changes of access rights by the user. +# The uid and gid of the directory are equal. Thus the name of the +# directory and the id from the group ownership also provide the +# association between the username and the uid. + +# Add the user with name $1 to the host's "user database". This +# may not be invoked concurrently. createUser() { local missing=$1 local uid local userHome="/home/$missing" local createOpts="" + + # Retrieve or create the uid for the username if [ -d "$userHome" ]; then - uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $3}') + # If a home directory exists, use the id from the group ownership as uid + uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $4}') createOpts="--no-create-home" else - uid=$(ls -ln "/home" | tail -n +2 | awk '{print $3}' | sort | tail -1) + # Else get the maximum of all ids from the group ownership +1 + uid=$(ls -ln "/home" | tail -n +2 | awk '{print $4}' | sort | tail -1) uid=$(( $uid + 1 )) - if [ $uid -lt 1000 ]; then - uid=1000 + if [ $uid -lt 1100 ]; then + uid=1100 fi createOpts="--create-home" fi @@ -44,33 +61,45 @@ createUser() { useradd $missing -u $uid -g $uid $createOpts } +# Login the user, i.e. create a desktopn for the user. doLogin() { user=$1 if [ "$user" = "root" ]; then echo >&${con} "504 Won't log in root" return fi + + # Check if this user is already logged in on tty1 curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty1") | .user') if [ "$curUser" = "$user" ]; then echo >&${con} "201 User already logged in" return fi + + # Terminate a running desktop (fail safe) attemptLogout + + # Check if username is known on this host. If not, create user uid=$(id -u ${user} 2>/dev/null) if [ $? != 0 ]; then ( flock 200 createUser ${user} ) 200>/home/.gen-uid-lock + + # This should now work, else something went wrong uid=$(id -u ${user} 2>/dev/null) if [ $? != 0 ]; then echo >&${con} "451 Cannot determine uid" return fi fi + + # Start the desktop for the user systemd-run 2>$temperr \ --unit vmop-user-desktop --uid=$uid --gid=$uid \ --working-directory="/home/$user" -p TTYPath=/dev/tty1 \ -p PAMName=login -p StandardInput=tty -p StandardOutput=journal \ + -p Conflicts="gdm.service getty@tty1.service" \ -E XDG_RUNTIME_DIR="/run/user/$uid" \ -p ExecStartPre="/usr/bin/chvt 1" \ dbus-run-session -- gnome-shell --display-server --wayland @@ -81,6 +110,8 @@ doLogin() { fi } +# Attempt to log out a user currently using tty1. This is an intermediate +# operation that can be invoked from other operations attemptLogout() { systemctl status vmop-user-desktop > /dev/null 2>&1 if [ $? = 0 ]; then @@ -93,6 +124,8 @@ attemptLogout() { echo >&${con} "102 Desktop stopped" } +# Log out any user currently using tty1. This is invoked when executing +# the logout command and therefore sends back a 2xx return code. doLogout() { attemptLogout echo >&${con} "202 User logged out" @@ -101,7 +134,7 @@ doLogout() { while read line <&${con}; do case $line in "login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};; - logout) doLogout;; + "logout") doLogout;; esac done From b4094434995859f8bc945e2c8aa601e87cf86f60 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 27 Feb 2025 22:54:19 +0100 Subject: [PATCH 21/29] Fix icon. --- .../vmoperator/vmaccess/osicons/Licenses.txt | 6 +++++- .../vmoperator/vmaccess/osicons/fedora.svg | Bin 41315 -> 1682 bytes 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt index f67978f..ac24b16 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt @@ -9,7 +9,11 @@ archlinux.svg: debian.svg: Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg License : LGPL - + +fedora.svg: + Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg + License: Public Domain + tux.svg: Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg index 78717917f660f45dd4c2e9d11c8af3b32c901589..e22731170af09a5afaadf2beec75dd226a58481d 100644 GIT binary patch literal 1682 zcmZ`)%WfPu5WMRvh{<)P)o+r4R)&+n$R)QVcR|`{7m#-iYoRsr^(l@X)*;qG8Wx+) z>gsCh>ch9s59Mk5^>KeZY$hR|%Jy)3yxSk{H`8B#ep=d89#1!iyPJpOVY`_Q$LYiM z#nt1}{Y5DShYydNX?Hq(`T6qld_L3J==k;iQknS@LDPsj@9$2#%@m@I3`Zce-P3yXjD4-cE^ zr;v~O+s8He2)Wx#f0V3Lt2dju{9263GBdDX;@WDPwNo|F*MW%UoC$_)F!>$4o=OPT z?6sBMOGvq0KdR4-Oud$*&eDCBAkNxQLy&dl{vFwsP_mjeaoQ@mc$0a-ooa26%$}U{ zGU$XfsNSUP(Tg&ccRM@i3Of9f(0}zo4Y0z&^ z$h;!RIxA^NczJl%*=HA=f=)UVhuEyGqd*dc$g>!EH`NL*Mzy)L_Zalc?6OidYHmfwwW z!(P&?{9O_!vUL+|FR%O9j@D(>)Jc=k!-(I6V2j#_e z!)9P5WUi&=O4BRr>od!{dG_u+g8ZbHuU62q{iOXMQyM>Zz z?(hwV_P`QGNZgH56`EjPM{t8}2E>>M6aj@XKaGdqpg%Mp##v(YtYUzGcEQ*Uy_5|z zgk7m{MIw4^GrA2Wq;X_{)-mqv=#aBnK|}c2BX3ZUR<0GAj&6ChfRDk?18sRl-l7@A z;E*>Ve9WMMb`MMwVo@mGD7-V$F>-^UK;DFjyJQw4 zQ17FDcU0KnVPWM_Gc^pJD-aiWbFI_!+4xN{wFp6@-33BO4Jd&&ns$dwF97BMkr?Bl qn?UM_j!vM^>MW2-$CCt;!LkYihNJaw*y}TNb%{s(YW%;rzW5K2k6CE| literal 41315 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 Date: Thu, 27 Feb 2025 22:55:50 +0100 Subject: [PATCH 22/29] Allow console take over. --- .../vmoperator/runner/qemu/DisplayController.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 6a3f783..479e2b1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -35,7 +35,6 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; -import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -53,7 +52,6 @@ public class DisplayController extends Component { private String protocol; private final Path configDir; private boolean vmopAgentConnected; - private boolean userLoginRequested; /** * Instantiates a new Display controller. @@ -112,13 +110,6 @@ public class DisplayController extends Component { var userLoginConfigured = readFromFile(DATA_DISPLAY_LOGIN) .map(Boolean::parseBoolean).orElse(false); if (!userLoginConfigured) { - // Check if it was configured before and there may thus be an - // active auto login - if (userLoginRequested && vmopAgentConnected) { - // Make sure to log out - fire(new VmopAgentLogOut()); - } - userLoginRequested = false; configurePassword(); return; } @@ -126,7 +117,6 @@ public class DisplayController extends Component { // With user login configured, we have to make sure that the // user is logged in before we set the password and thus allow // access to the display. - userLoginRequested = true; if (!vmopAgentConnected) { if (passwordChange) { logger.warning(() -> "Request for user login before " From 5366e240928a43d5114536727dc9b0da55ba6a24 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 11:02:52 +0100 Subject: [PATCH 23/29] Move automatic login request to CRD. Also reorganizes constants. --- deploy/crds/vms-crd.yaml | 11 ++ dev-example/vmop-agent/vmop-agent | 10 ++ .../jdrupes/vmoperator/common/Constants.java | 52 +++++--- .../vmoperator/common/K8sGenericStub.java | 4 +- .../vmoperator/manager/runnerConfig.ftl.yaml | 3 + .../manager/ConfigMapReconciler.java | 4 +- .../vmoperator/manager/Controller.java | 7 +- .../manager/DisplaySecretMonitor.java | 8 +- .../manager/DisplaySecretReconciler.java | 112 ++++++++---------- .../jdrupes/vmoperator/manager/Manager.java | 6 +- .../vmoperator/manager/PoolMonitor.java | 8 +- .../vmoperator/manager/PvcReconciler.java | 4 +- .../vmoperator/manager/Reconciler.java | 4 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 8 +- .../vmoperator/manager/BasicTests.java | 30 +++-- .../logging.properties | 4 +- .../vmoperator/runner/qemu/Configuration.java | 3 + .../runner/qemu/ConsoleTracker.java | 9 +- .../runner/qemu/DisplayController.java | 94 ++++++--------- .../vmoperator/runner/qemu/Runner.java | 4 +- .../vmoperator/runner/qemu/StatusUpdater.java | 62 ++++++++-- webpages/vm-operator/pools.md | 18 ++- 22 files changed, 259 insertions(+), 206 deletions(-) diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 749b896..2a14f0c 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1430,6 +1430,12 @@ spec: outputs: type: integer default: 1 + loggedInUser: + description: >- + The name of a user that should be automatically + logged in on the display. Note that this requires + support from an agent in the guest OS. + type: string spice: type: object properties: @@ -1485,6 +1491,11 @@ spec: connection. type: string default: "" + loggedInUser: + description: >- + The name of a user that is currently logged in by the + VM operator agent. + type: string displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index 99aa25d..bb2fb86 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -126,8 +126,18 @@ attemptLogout() { # Log out any user currently using tty1. This is invoked when executing # the logout command and therefore sends back a 2xx return code. +# Also try to restart gdm, if it is not running. doLogout() { attemptLogout + systemctl status gdm >/dev/null 2>&1 + if [ $? != 0 ]; then + systemctl restart gdm 2>$temperr + if [ $? -eq 0 ]; then + echo >&${con} "102 gdm restarted" + else + echo >&${con} "102 Restarting gdm failed: $(tr '\n' ' ' <${temperr})" + fi + fi echo >&${con} "202 User logged out" } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 9bfba8d..a7ed780 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -27,31 +27,47 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; - /** The Constant VM_OP_NAME. */ - public static final String VM_OP_NAME = "vm-operator"; + /** + * Constants related to the CRD. + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Crd { - /** The Constant VM_OP_GROUP. */ - public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; + /** The Constant NAME. */ + public static final String NAME = "vm-operator"; - /** The Constant VM_OP_KIND_VM. */ - public static final String VM_OP_KIND_VM = "VirtualMachine"; + /** The Constant GROUP. */ + public static final String GROUP = "vmoperator.jdrupes.org"; - /** The Constant VM_OP_KIND_VM_POOL. */ - public static final String VM_OP_KIND_VM_POOL = "VmPool"; + /** The Constant KIND_VM. */ + public static final String KIND_VM = "VirtualMachine"; - /** The Constant COMP_DISPLAY_SECRETS. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; + /** The Constant KIND_VM_POOL. */ + public static final String KIND_VM_POOL = "VmPool"; + } - /** The Constant DATA_DISPLAY_PASSWORD. */ - public static final String DATA_DISPLAY_PASSWORD = "display-password"; + /** + * Constants for the display secret. + */ + public static class DisplaySecret { - /** The Constant DATA_PASSWORD_EXPIRY. */ - public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + /** The Constant NAME. */ + public static final String NAME = "display-secret"; - /** The Constant DATA_DISPLAY_USER. */ - public static final String DATA_DISPLAY_USER = "display-user"; + /** The Constant DISPLAY_PASSWORD. */ + public static final String DISPLAY_PASSWORD = "display-password"; - /** The Constant DATA_DISPLAY_LOGIN. */ - public static final String DATA_DISPLAY_LOGIN = "login-user"; + /** The Constant PASSWORD_EXPIRY. */ + public static final String PASSWORD_EXPIRY = "password-expiry"; + } + /** + * Constants for status fields. + */ + public static class Status { + + /** The Constant LOGGED_IN_USER. */ + public static final String LOGGED_IN_USER = "loggedInUser"; + + } } 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 688f43f..b1db86f 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,7 @@ public class K8sGenericStub outputs: ${ spec.vm.display.outputs?c } + <#if spec.vm.display.loggedInUser?? > + loggedInUser: "${ spec.vm.display.loggedInUser }" + <#if spec.vm.display.spice??> spice: port: ${ spec.vm.display.spice.port?c } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 9c6dc3e..c5daf73 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -33,9 +33,9 @@ import java.io.IOException; import java.io.StringWriter; import java.util.Map; import java.util.logging.Logger; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; @@ -121,7 +121,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; DynamicKubernetesObject newCm) { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + newCm.getMetadata() .getLabels().get("app.kubernetes.io/instance")); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 80ff0f7..f3deefa 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -29,8 +29,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.logging.Level; -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.Constants.Crd; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -194,7 +193,7 @@ public class Controller extends Component { private void patchVmDef(K8sClient client, String name, String path, Object value) throws ApiException, IOException { var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, name); // Patch running @@ -227,7 +226,7 @@ public class Controller extends Component { try { var vmDef = channel.vmDefinition(); var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); if (vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); 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 99c8a11..66cd2f4 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 @@ -28,11 +28,11 @@ import io.kubernetes.client.util.generic.options.PatchOptions; 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_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jgrapes.core.Channel; @@ -61,7 +61,7 @@ public class DisplaySecretMonitor context(K8sV1SecretStub.CONTEXT); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); options(options); } @@ -95,7 +95,7 @@ public class DisplaySecretMonitor // Force update for pod ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + change.object.getMetadata() .getLabels().get("app.kubernetes.io/instance")); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index e1955b4..24ba978 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -26,7 +26,6 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; -import static java.nio.charset.StandardCharsets.UTF_8; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; @@ -34,20 +33,16 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Scanner; import java.util.logging.Logger; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_LOGIN; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_USER; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -146,7 +141,7 @@ public class DisplaySecretReconciler extends Component { var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -157,9 +152,9 @@ public class DisplaySecretReconciler extends Component { // Create secret var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) + .name(vmDef.name() + "-" + DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/name", APP_NAME) - .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; @@ -172,8 +167,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, "now")); + secret.setStringData(Map.of(DisplaySecret.DISPLAY_PASSWORD, password, + DisplaySecret.PASSWORD_EXPIRY, "now")); K8sV1SecretStub.create(channel.client(), secret); } @@ -192,49 +187,31 @@ public class DisplaySecretReconciler extends Component { public void onPrepareConsole(PrepareConsole event, VmChannel channel) throws ApiException { // Update console user in status - var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - event.vmDefinition().namespace(), event.vmDefinition().name()); - var optVmDef = vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleUser", event.user()); - return status; - }); - if (optVmDef.isEmpty()) { + var vmDef = updateConsoleUser(event, channel); + if (vmDef == null) { return; } - var vmDef = optVmDef.get(); // Check if access is possible if (event.loginUser() - ? !vmDef.conditionStatus("Booted").orElse(false) + ? !vmDef. fromStatus(Status.LOGGED_IN_USER) + .map(u -> u.equals(event.user())).orElse(false) : !vmDef.conditionStatus("Running").orElse(false)) { return; } - // Look for secret - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), - options); - if (stubs.isEmpty()) { - // No secret means no password for this VM wanted - event.setResult(null); + // Get secret and update password in secret + var stub = getSecretStub(event, channel, vmDef); + if (stub == null) { return; } - var stub = stubs.iterator().next(); - - // Get secret and update var secret = stub.model().get(); - var updPw = updatePassword(secret, event); - var updUsr = updateUser(secret, event); - if (!updPw && !updUsr) { + if (!updatePassword(secret, event)) { return; } - // Register wait for confirmation (by VM status change) + // Register wait for confirmation (by VM status change, + // after secret update) var pending = new PendingPrepare(event, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, new CompletionLock(event, 1500)); @@ -247,30 +224,45 @@ public class DisplaySecretReconciler extends Component { stub.update(secret).getObject(); } - private boolean updateUser(V1Secret secret, PrepareConsole event) { - var curUser = DataPath. get(secret, "data", DATA_DISPLAY_USER) - .map(b -> new String(b, UTF_8)).orElse(null); - var curLogin = DataPath. get(secret, "data", DATA_DISPLAY_LOGIN) - .map(b -> new String(b, UTF_8)).map(Boolean::parseBoolean) - .orElse(null); - if (Objects.equals(curUser, event.user()) && Objects.equals( - curLogin, event.loginUser())) { - return false; + private VmDefinition updateConsoleUser(PrepareConsole event, + VmChannel channel) throws ApiException { + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + return vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty("consoleUser", event.user()); + return status; + }).orElse(null); + } + + private K8sV1SecretStub getSecretStub(PrepareConsole event, + VmChannel channel, VmDefinition vmDef) throws ApiException { + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return null; } - secret.getData().put(DATA_DISPLAY_USER, event.user().getBytes(UTF_8)); - secret.getData().put(DATA_DISPLAY_LOGIN, - Boolean.toString(event.loginUser()).getBytes(UTF_8)); - return true; + return stubs.iterator().next(); } private boolean updatePassword(V1Secret secret, PrepareConsole event) { var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + .get(DisplaySecret.PASSWORD_EXPIRY)).map(b -> new String(b)) + .orElse(null); + if (secret.getData().get(DisplaySecret.DISPLAY_PASSWORD) != null && stillValid(expiry)) { // Fixed secret, don't touch event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + new String( + secret.getData().get(DisplaySecret.DISPLAY_PASSWORD))); return false; } @@ -285,8 +277,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, + secret.setStringData(Map.of(DisplaySecret.DISPLAY_PASSWORD, password, + DisplaySecret.PASSWORD_EXPIRY, Long.toString(Instant.now().getEpochSecond() + passwordValidity))); event.setResult(password); return true; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index 9d291cf..8acddc5 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -40,7 +40,7 @@ import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; @@ -108,7 +108,7 @@ public class Manager extends Component { // Configuration store with file in /etc/opt (default) File cfgFile = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")); + "/etc/opt/" + Crd.NAME.replace("-", "") + "/config.yaml")); logger.config(() -> "Using configuration from: " + cfgFile.getPath()); // Don't rely on night config to produce a good exception // for this simple case @@ -271,7 +271,7 @@ public class Manager extends Component { try { // Get logging properties from file and put them in effect InputStream props; - var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""), + var path = FsdUtils.findConfigFile(Crd.NAME.replace("-", ""), "logging.properties"); if (path.isPresent()) { props = Files.newInputStream(path.get()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 25fb10b..465a9ed 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -28,8 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -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.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; @@ -38,7 +37,6 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; @@ -88,7 +86,7 @@ public class PoolMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -184,7 +182,7 @@ public class PoolMonitor extends return; } var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { // TODO diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 34085f0..f0a3ede 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -36,7 +36,7 @@ import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8sV1PvcStub; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -83,7 +83,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Existing disks ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var knownDisks = K8sV1PvcStub.list(channel.client(), diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 8011e2c..0622a7c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -46,12 +46,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -276,7 +276,7 @@ public class Reconciler extends Component { // Check if we have a display secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub .list(client, vmDef.namespace(), options) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 5c1ae77..a253b17 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -31,8 +31,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.stream.Collectors; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -46,7 +45,6 @@ import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.GetPools; @@ -87,7 +85,7 @@ public class VmMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -105,7 +103,7 @@ public class VmMonitor extends .stream().map(stub -> stub.name()).collect(Collectors.toSet()); ListOptions opts = new ListOptions(); opts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/name=" + APP_NAME); for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, K8sV1ConfigMapStub.CONTEXT)) { diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 4f5d7a3..b3b9b1a 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -13,10 +13,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -60,7 +58,7 @@ class BasicTests { waitForManager(); // Context for working with our CR - var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); + var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); @@ -70,7 +68,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); for (var secret : secrets) { secret.delete(); @@ -100,7 +98,7 @@ class BasicTests { private static void deletePvcs() throws ApiException { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME); var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); @@ -139,11 +137,11 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + Crd.NAME, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS); checkProps(config.getMetadata(), toCheck); @@ -189,7 +187,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); Collection secrets = null; for (int i = 0; i < 10; i++) { secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); @@ -220,7 +218,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + Crd.NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Mi"))); @@ -241,7 +239,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + Crd.NAME, List.of("annotations", "use_as"), "system-disk")); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), @@ -263,7 +261,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + Crd.NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Gi"))); @@ -291,12 +289,12 @@ class BasicTests { List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/component"), APP_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + Crd.NAME, List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS)); checkProps(pod.getSpec(), Map.of( @@ -319,7 +317,7 @@ class BasicTests { checkProps(svc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), Crd.NAME, List.of("labels", "label1"), "label1", List.of("labels", "label2"), "replaced", List.of("labels", "label3"), "added", diff --git a/org.jdrupes.vmoperator.runner.qemu/logging.properties b/org.jdrupes.vmoperator.runner.qemu/logging.properties index 1cf84fe..6b0542d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/logging.properties +++ b/org.jdrupes.vmoperator.runner.qemu/logging.properties @@ -19,8 +19,8 @@ handlers=java.util.logging.ConsoleHandler -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER +#org.jgrapes.level=FINE +#org.jgrapes.core.handlerTracking.level=FINER org.jdrupes.vmoperator.runner.qemu.level=FINE diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index 20d4c66..50635b5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -248,6 +248,9 @@ public class Configuration implements Dto { /** The number of outputs. */ public int outputs = 1; + /** The logged in user. */ + public String loggedInUser; + /** The spice. */ public Spice spice; } 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 b91b5df..db29932 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 @@ -25,8 +25,7 @@ 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.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -74,7 +73,7 @@ public class ConsoleTracker extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -115,7 +114,7 @@ public class ConsoleTracker extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("ConsoleConnectionUpdate") .reason("Connection from " + event.clientHost()); K8s.createEvent(apiClient, vmStub.model().get(), evt); @@ -150,7 +149,7 @@ public class ConsoleTracker extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.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/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 479e2b1..63a3ec1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,10 +24,7 @@ import java.nio.file.Path; import java.util.Objects; import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_LOGIN; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_USER; -import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -35,9 +32,10 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; -import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; +import org.jgrapes.core.Event; import org.jgrapes.core.annotation.Handler; import org.jgrapes.util.events.FileChanged; import org.jgrapes.util.events.WatchFile; @@ -52,6 +50,7 @@ public class DisplayController extends Component { private String protocol; private final Path configDir; private boolean vmopAgentConnected; + private String loggedInUser; /** * Instantiates a new Display controller. @@ -64,17 +63,7 @@ public class DisplayController extends Component { public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD))); - } - - /** - * On vmop agent connected. - * - * @param event the event - */ - @Handler - public void onVmopAgentConnected(VmopAgentConnected event) { - vmopAgentConnected = true; + fire(new WatchFile(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD))); } /** @@ -89,7 +78,32 @@ public class DisplayController extends Component { } protocol = event.configuration().vm.display.spice != null ? "spice" : null; - configureAccess(false); + loggedInUser = event.configuration().vm.display.loggedInUser; + configureLogin(); + if (event.runState() == RunState.STARTING) { + configurePassword(); + } + } + + /** + * On vmop agent connected. + * + * @param event the event + */ + @Handler + public void onVmopAgentConnected(VmopAgentConnected event) { + vmopAgentConnected = true; + configureLogin(); + } + + private void configureLogin() { + if (!vmopAgentConnected) { + return; + } + Event evt = loggedInUser != null + ? new VmopAgentLogIn(loggedInUser) + : new VmopAgentLogOut(); + fire(evt); } /** @@ -100,46 +114,10 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) { - configureAccess(true); - } - } - - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") - private void configureAccess(boolean passwordChange) { - var userLoginConfigured = readFromFile(DATA_DISPLAY_LOGIN) - .map(Boolean::parseBoolean).orElse(false); - if (!userLoginConfigured) { + if (event.path() + .equals(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD))) { configurePassword(); - return; } - - // With user login configured, we have to make sure that the - // user is logged in before we set the password and thus allow - // access to the display. - if (!vmopAgentConnected) { - if (passwordChange) { - logger.warning(() -> "Request for user login before " - + "VM operator agent has connected"); - } - return; - } - - var user = readFromFile(DATA_DISPLAY_USER); - if (user.isEmpty()) { - logger.warning(() -> "Login requested, but no user configured"); - } - fire(new VmopAgentLogIn(user.get()).setAssociated(this, user.get())); - } - - /** - * On vmop agent logged in. - * - * @param event the event - */ - @Handler - public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) { - configurePassword(); } private void configurePassword() { @@ -152,7 +130,7 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - return readFromFile(DATA_DISPLAY_PASSWORD).map(password -> { + return readFromFile(DisplaySecret.DISPLAY_PASSWORD).map(password -> { if (Objects.equals(this.currentPassword, password)) { return true; } @@ -165,7 +143,7 @@ public class DisplayController extends Component { } private void setPasswordExpiry() { - readFromFile(DATA_PASSWORD_EXPIRY).ifPresent(expiry -> { + readFromFile(DisplaySecret.PASSWORD_EXPIRY).ifPresent(expiry -> { logger.fine(() -> "Updating expiry time to " + expiry); fire( new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index f64af2d..0c62a93 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -56,7 +56,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -312,7 +312,7 @@ public class Runner extends Component { // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath = configDir.resolve(DATA_DISPLAY_PASSWORD); + Path dsPath = configDir.resolve(DisplaySecret.DISPLAY_PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) 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 fa0e3ab..9c149d5 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,8 +33,8 @@ import java.io.IOException; import java.math.BigDecimal; 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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -48,6 +48,8 @@ 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.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.annotation.Handler; @@ -110,11 +112,17 @@ public class StatusUpdater extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); - vmStub.model().ifPresent(model -> { - observedGeneration = model.getMetadata().getGeneration(); - }); + var vmDef = vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + return status; + }).orElse(null); + if (vmDef == null) { + return; + } + observedGeneration = vmDef.getMetadata().getGeneration(); } catch (ApiException e) { logger.log(Level.SEVERE, e, () -> "Cannot access VM object, terminating."); @@ -152,7 +160,7 @@ public class StatusUpdater extends VmDefUpdater { "displayPasswordSerial").getAsInt() == -1)) { return; } - vmStub.updateStatus(vmDef.get(), from -> { + vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { status.addProperty("displayPasswordSerial", -1); @@ -173,15 +181,15 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.AssignmentInOperand", - "PMD.AvoidLiteralsInIfCondition" }) + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { VmDefinition vmDef; if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - vmStub.updateStatus(vmDef, from -> { + vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); boolean running = event.runState().vmRunning(); updateCondition(vmDef, vmDef.statusJson(), "Running", running, @@ -196,6 +204,7 @@ public class StatusUpdater extends VmDefUpdater { } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); + status.remove(Status.LOGGED_IN_USER); } if (!running) { @@ -228,7 +237,7 @@ public class StatusUpdater extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("StatusUpdate").reason(event.reason()) .note(event.message()); K8s.createEvent(apiClient, vmDef, evt); @@ -344,4 +353,35 @@ public class StatusUpdater extends VmDefUpdater { return status; }); } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.LOGGED_IN_USER, + event.triggering().user()); + return status; + }); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + return status; + }); + } } diff --git a/webpages/vm-operator/pools.md b/webpages/vm-operator/pools.md index 290d0fd..a42293e 100644 --- a/webpages/vm-operator/pools.md +++ b/webpages/vm-operator/pools.md @@ -12,11 +12,12 @@ layout: vm-operator ### Shared file system Mount a shared file system as home file system on all VMs in the pool. +If you want to use the sample script for logging in a user, the filesystem +must support POSIX file access control lists (ACLs). ### Restrict access -The only possibility to access the VMs should be via a desktop started by -the VM-Operator. +The VMs should only be accessible via a desktop started by the VM-Operator. * Disable the display manager. @@ -31,10 +32,17 @@ the VM-Operator. # systemctl mask getty@tty1 # systemctl stop getty@tty1 ``` + +You can, of course, disable `getty` completely. If you do this, make sure +that you can still access your master VM through `ssh`, else you have +locked yourself out. + +Strictly speaking, it is not necessary to disable these services, because +the sample script includes a `Conflicts=` directive in the systemd service +that starts the desktop for the user. However, this is mainly intended for +development purposes and not for production. - You can, of course, disable `getty` completely. If you do this, make sure - that you can still access your master VM through `ssh`, else you have - locked yourself out. +The following should actually be configured for any VM. * Prevent suspend/hibernate, because it will lock the VM. From 5e282c4d2b298e96ebae18c9e095a28c88587a21 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 15:44:05 +0100 Subject: [PATCH 24/29] Move automatic login request to CRD. Undoes reorganize constants. --- .../jdrupes/vmoperator/common/Constants.java | 51 +++++-------------- .../vmoperator/common/K8sGenericStub.java | 4 +- .../manager/ConfigMapReconciler.java | 4 +- .../vmoperator/manager/Controller.java | 7 +-- .../manager/DisplaySecretMonitor.java | 8 +-- .../manager/DisplaySecretReconciler.java | 36 ++++++------- .../jdrupes/vmoperator/manager/Manager.java | 6 +-- .../vmoperator/manager/PoolMonitor.java | 8 +-- .../vmoperator/manager/PvcReconciler.java | 4 +- .../vmoperator/manager/Reconciler.java | 4 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 8 +-- .../vmoperator/manager/BasicTests.java | 30 ++++++----- .../logging.properties | 4 +- .../runner/qemu/ConsoleTracker.java | 9 ++-- .../runner/qemu/DisplayController.java | 12 ++--- .../vmoperator/runner/qemu/Runner.java | 4 +- .../vmoperator/runner/qemu/StatusUpdater.java | 30 ++++------- 17 files changed, 103 insertions(+), 126 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index a7ed780..7a1bd1a 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -27,47 +27,24 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; - /** - * Constants related to the CRD. - */ - @SuppressWarnings("PMD.ShortClassName") - public static class Crd { + /** The Constant VM_OP_NAME. */ + public static final String VM_OP_NAME = "vm-operator"; - /** The Constant NAME. */ - public static final String NAME = "vm-operator"; + /** The Constant VM_OP_GROUP. */ + public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; - /** The Constant GROUP. */ - public static final String GROUP = "vmoperator.jdrupes.org"; + /** The Constant VM_OP_KIND_VM. */ + public static final String VM_OP_KIND_VM = "VirtualMachine"; - /** The Constant KIND_VM. */ - public static final String KIND_VM = "VirtualMachine"; + /** The Constant VM_OP_KIND_VM_POOL. */ + public static final String VM_OP_KIND_VM_POOL = "VmPool"; - /** The Constant KIND_VM_POOL. */ - public static final String KIND_VM_POOL = "VmPool"; - } + /** The Constant COMP_DISPLAY_SECRETS. */ + public static final String COMP_DISPLAY_SECRET = "display-secret"; - /** - * Constants for the display secret. - */ - public static class DisplaySecret { + /** The Constant DATA_DISPLAY_PASSWORD. */ + public static final String DATA_DISPLAY_PASSWORD = "display-password"; - /** The Constant NAME. */ - public static final String NAME = "display-secret"; - - /** The Constant DISPLAY_PASSWORD. */ - public static final String DISPLAY_PASSWORD = "display-password"; - - /** The Constant PASSWORD_EXPIRY. */ - public static final String PASSWORD_EXPIRY = "password-expiry"; - } - - /** - * Constants for status fields. - */ - public static class Status { - - /** The Constant LOGGED_IN_USER. */ - public static final String LOGGED_IN_USER = "loggedInUser"; - - } + /** The Constant DATA_PASSWORD_EXPIRY. */ + public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; } 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 b1db86f..688f43f 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,7 @@ public class K8sGenericStub { JsonObject status = from.statusJson(); 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 66cd2f4..99c8a11 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 @@ -28,11 +28,11 @@ import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.Crd; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +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 static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jgrapes.core.Channel; @@ -61,7 +61,7 @@ public class DisplaySecretMonitor context(K8sV1SecretStub.CONTEXT); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME); + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); options(options); } @@ -95,7 +95,7 @@ public class DisplaySecretMonitor // Force update for pod ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + change.object.getMetadata() .getLabels().get("app.kubernetes.io/instance")); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 24ba978..bf8042a 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -37,12 +37,14 @@ import java.util.Optional; import java.util.Scanner; import java.util.logging.Logger; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.Crd; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; -import org.jdrupes.vmoperator.common.Constants.Status; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; +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.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -141,7 +143,7 @@ public class DisplaySecretReconciler extends Component { var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -152,9 +154,9 @@ public class DisplaySecretReconciler extends Component { // Create secret var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + DisplaySecret.NAME) + .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) .putLabelsItem("app.kubernetes.io/name", APP_NAME) - .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) + .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; @@ -167,8 +169,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DisplaySecret.DISPLAY_PASSWORD, password, - DisplaySecret.PASSWORD_EXPIRY, "now")); + secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, "now")); K8sV1SecretStub.create(channel.client(), secret); } @@ -194,7 +196,7 @@ public class DisplaySecretReconciler extends Component { // Check if access is possible if (event.loginUser() - ? !vmDef. fromStatus(Status.LOGGED_IN_USER) + ? !vmDef. fromStatus("loggedInUser") .map(u -> u.equals(event.user())).orElse(false) : !vmDef.conditionStatus("Running").orElse(false)) { return; @@ -227,7 +229,7 @@ public class DisplaySecretReconciler extends Component { private VmDefinition updateConsoleUser(PrepareConsole event, VmChannel channel) throws ApiException { var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); return vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); @@ -241,7 +243,7 @@ public class DisplaySecretReconciler extends Component { // Look for secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -255,14 +257,12 @@ public class DisplaySecretReconciler extends Component { private boolean updatePassword(V1Secret secret, PrepareConsole event) { var expiry = Optional.ofNullable(secret.getData() - .get(DisplaySecret.PASSWORD_EXPIRY)).map(b -> new String(b)) - .orElse(null); - if (secret.getData().get(DisplaySecret.DISPLAY_PASSWORD) != null + .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null && stillValid(expiry)) { // Fixed secret, don't touch event.setResult( - new String( - secret.getData().get(DisplaySecret.DISPLAY_PASSWORD))); + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); return false; } @@ -277,8 +277,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DisplaySecret.DISPLAY_PASSWORD, password, - DisplaySecret.PASSWORD_EXPIRY, + secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, Long.toString(Instant.now().getEpochSecond() + passwordValidity))); event.setResult(password); return true; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index 8acddc5..9d291cf 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -40,7 +40,7 @@ import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; -import org.jdrupes.vmoperator.common.Constants.Crd; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; @@ -108,7 +108,7 @@ public class Manager extends Component { // Configuration store with file in /etc/opt (default) File cfgFile = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + Crd.NAME.replace("-", "") + "/config.yaml")); + "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")); logger.config(() -> "Using configuration from: " + cfgFile.getPath()); // Don't rely on night config to produce a good exception // for this simple case @@ -271,7 +271,7 @@ public class Manager extends Component { try { // Get logging properties from file and put them in effect InputStream props; - var path = FsdUtils.findConfigFile(Crd.NAME.replace("-", ""), + var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""), "logging.properties"); if (path.isPresent()) { props = Files.newInputStream(path.get()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 465a9ed..25fb10b 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -28,7 +28,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import org.jdrupes.vmoperator.common.Constants.Crd; +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; @@ -37,6 +38,7 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; @@ -86,7 +88,7 @@ public class PoolMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); + var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -182,7 +184,7 @@ public class PoolMonitor extends return; } var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { // TODO diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index f0a3ede..34085f0 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -36,7 +36,7 @@ import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.Crd; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sV1PvcStub; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -83,7 +83,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Existing disks ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var knownDisks = K8sV1PvcStub.list(channel.client(), diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 0622a7c..8011e2c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -46,12 +46,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; +import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -276,7 +276,7 @@ public class Reconciler extends Component { // Check if we have a display secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub .list(client, vmDef.namespace(), options) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index a253b17..5c1ae77 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -31,7 +31,8 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.stream.Collectors; -import org.jdrupes.vmoperator.common.Constants.Crd; +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.K8sDynamicStub; @@ -45,6 +46,7 @@ import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.GetPools; @@ -85,7 +87,7 @@ public class VmMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); + var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -103,7 +105,7 @@ public class VmMonitor extends .stream().map(stub -> stub.name()).collect(Collectors.toSet()); ListOptions opts = new ListOptions(); opts.setLabelSelector( - "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME); for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, K8sV1ConfigMapStub.CONTEXT)) { diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index b3b9b1a..4f5d7a3 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -13,8 +13,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.Crd; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; +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.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -58,7 +60,7 @@ class BasicTests { waitForManager(); // Context for working with our CR - var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); + var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); @@ -68,7 +70,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME); + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); for (var secret : secrets) { secret.delete(); @@ -98,7 +100,7 @@ class BasicTests { private static void deletePvcs() throws ApiException { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + Crd.NAME + "," + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME); var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); @@ -137,11 +139,11 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Crd.NAME, + Constants.VM_OP_NAME, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS); checkProps(config.getMetadata(), toCheck); @@ -187,7 +189,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME); + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); Collection secrets = null; for (int i = 0; i < 10; i++) { secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); @@ -218,7 +220,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Crd.NAME)); + Constants.VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Mi"))); @@ -239,7 +241,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Crd.NAME, + Constants.VM_OP_NAME, List.of("annotations", "use_as"), "system-disk")); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), @@ -261,7 +263,7 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Crd.NAME)); + Constants.VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Gi"))); @@ -289,12 +291,12 @@ class BasicTests { List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/component"), APP_NAME, List.of("labels", "app.kubernetes.io/managed-by"), - Crd.NAME, + Constants.VM_OP_NAME, List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS)); checkProps(pod.getSpec(), Map.of( @@ -317,7 +319,7 @@ class BasicTests { checkProps(svc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), Crd.NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("labels", "label1"), "label1", List.of("labels", "label2"), "replaced", List.of("labels", "label3"), "added", diff --git a/org.jdrupes.vmoperator.runner.qemu/logging.properties b/org.jdrupes.vmoperator.runner.qemu/logging.properties index 6b0542d..1cf84fe 100644 --- a/org.jdrupes.vmoperator.runner.qemu/logging.properties +++ b/org.jdrupes.vmoperator.runner.qemu/logging.properties @@ -19,8 +19,8 @@ handlers=java.util.logging.ConsoleHandler -#org.jgrapes.level=FINE -#org.jgrapes.core.handlerTracking.level=FINER +org.jgrapes.level=FINE +org.jgrapes.core.handlerTracking.level=FINER org.jdrupes.vmoperator.runner.qemu.level=FINE 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 db29932..b91b5df 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 @@ -25,7 +25,8 @@ 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 org.jdrupes.vmoperator.common.Constants.Crd; +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; @@ -73,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, vmName); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -114,7 +115,7 @@ public class ConsoleTracker extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(Crd.GROUP + "/" + APP_NAME) + .reportingController(VM_OP_GROUP + "/" + APP_NAME) .action("ConsoleConnectionUpdate") .reason("Connection from " + event.clientHost()); K8s.createEvent(apiClient, vmStub.model().get(), evt); @@ -149,7 +150,7 @@ public class ConsoleTracker extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(Crd.GROUP + "/" + APP_NAME) + .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/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 63a3ec1..39f71d5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -24,7 +24,8 @@ import java.nio.file.Path; import java.util.Objects; import java.util.Optional; import java.util.logging.Level; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -63,7 +64,7 @@ public class DisplayController extends Component { public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD))); + fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD))); } /** @@ -114,8 +115,7 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path() - .equals(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD))) { + if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) { configurePassword(); } } @@ -130,7 +130,7 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - return readFromFile(DisplaySecret.DISPLAY_PASSWORD).map(password -> { + return readFromFile(DATA_DISPLAY_PASSWORD).map(password -> { if (Objects.equals(this.currentPassword, password)) { return true; } @@ -143,7 +143,7 @@ public class DisplayController extends Component { } private void setPasswordExpiry() { - readFromFile(DisplaySecret.PASSWORD_EXPIRY).ifPresent(expiry -> { + readFromFile(DATA_PASSWORD_EXPIRY).ifPresent(expiry -> { logger.fine(() -> "Updating expiry time to " + expiry); fire( new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 0c62a93..f64af2d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -56,7 +56,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -312,7 +312,7 @@ public class Runner extends Component { // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath = configDir.resolve(DisplaySecret.DISPLAY_PASSWORD); + Path dsPath = configDir.resolve(DATA_DISPLAY_PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) 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 9c149d5..eeee5ac 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,8 +33,8 @@ import java.io.IOException; import java.math.BigDecimal; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.Crd; -import org.jdrupes.vmoperator.common.Constants.Status; +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.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -112,17 +112,11 @@ public class StatusUpdater extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, vmName); - var vmDef = vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.remove(Status.LOGGED_IN_USER); - return status; - }).orElse(null); - if (vmDef == null) { - return; - } - observedGeneration = vmDef.getMetadata().getGeneration(); + vmStub.model().ifPresent(model -> { + observedGeneration = model.getMetadata().getGeneration(); + }); } catch (ApiException e) { logger.log(Level.SEVERE, e, () -> "Cannot access VM object, terminating."); @@ -160,7 +154,7 @@ public class StatusUpdater extends VmDefUpdater { "displayPasswordSerial").getAsInt() == -1)) { return; } - vmStub.updateStatus(from -> { + vmStub.updateStatus(vmDef.get(), from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { status.addProperty("displayPasswordSerial", -1); @@ -189,7 +183,7 @@ public class StatusUpdater extends VmDefUpdater { if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - vmStub.updateStatus(from -> { + vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); boolean running = event.runState().vmRunning(); updateCondition(vmDef, vmDef.statusJson(), "Running", running, @@ -204,7 +198,6 @@ public class StatusUpdater extends VmDefUpdater { } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); - status.remove(Status.LOGGED_IN_USER); } if (!running) { @@ -237,7 +230,7 @@ public class StatusUpdater extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(Crd.GROUP + "/" + APP_NAME) + .reportingController(VM_OP_GROUP + "/" + APP_NAME) .action("StatusUpdate").reason(event.reason()) .note(event.message()); K8s.createEvent(apiClient, vmDef, evt); @@ -364,8 +357,7 @@ public class StatusUpdater extends VmDefUpdater { throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty(Status.LOGGED_IN_USER, - event.triggering().user()); + status.addProperty("loggedInUser", event.triggering().user()); return status; }); } @@ -380,7 +372,7 @@ public class StatusUpdater extends VmDefUpdater { throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.remove(Status.LOGGED_IN_USER); + status.remove("loggedInUser"); return status; }); } From 62a72101176923701f57da155591e9d51f942ae5 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 17:00:14 +0100 Subject: [PATCH 25/29] Fix and clarify usage of methods for status update. --- .../vmoperator/common/K8sGenericStub.java | 35 ++++++++++--------- .../runner/qemu/ConsoleTracker.java | 10 +++--- .../vmoperator/runner/qemu/StatusUpdater.java | 23 ++++++------ .../vmoperator/runner/qemu/VmDefUpdater.java | 16 ++++++--- 4 files changed, 43 insertions(+), 41 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 688f43f..2af4d1b 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 @@ -196,51 +196,52 @@ public class K8sGenericStub updateStatus(O object, Function status) + public Optional updateStatus(O object, Function updater) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + return K8s.optional(api.updateStatus(object, updater)); } /** * Gets the object and updates the status. In case of conflict, retries * up to `retries` times. * - * @param status the status + * @param updater the function updating the status * @param retries the retries in case of conflict * @return the updated model or empty if the object was not found * @throws ApiException the api exception */ @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" }) - public Optional updateStatus(Function status, int retries) + public Optional updateStatus(Function updater, int retries) throws ApiException { - try { - return updateStatus(api.get(namespace, name).throwsApiException() - .getObject(), status); - } catch (ApiException e) { - if (HttpURLConnection.HTTP_CONFLICT != e.getCode() - || retries-- <= 0) { - throw e; + while (true) { + try { + return updateStatus(api.get(namespace, name) + .throwsApiException().getObject(), updater); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } } } - return Optional.empty(); } /** - * Updates the status. + * Updates the status. In case of conflict, retries up to 16 times. * - * @param status the status + * @param updater the function updating the status * @return the kubernetes api response * the updated model or empty if not successful * @throws ApiException the api exception */ - public Optional updateStatus(Function status) + public Optional updateStatus(Function updater) throws ApiException { - return updateStatus(status, 16); + return updateStatus(updater, 16); } /** 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 b91b5df..7956fa1 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 @@ -106,10 +106,9 @@ public class ConsoleTracker extends VmDefUpdater { mainChannelClientHost = event.clientHost(); mainChannelClientPort = event.clientPort(); vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); + JsonObject status = updateCondition(from, "ConsoleConnected", true, + "Connected", "Connection from " + event.clientHost()); status.addProperty("consoleClient", event.clientHost()); - updateCondition(from, status, "ConsoleConnected", true, "Connected", - "Connection from " + event.clientHost()); return status; }); @@ -141,10 +140,9 @@ public class ConsoleTracker extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", false, + JsonObject status = updateCondition(from, "ConsoleConnected", false, "Disconnected", event.clientHost() + " has disconnected"); + status.addProperty("consoleClient", ""); 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 eeee5ac..b4608b3 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 @@ -154,7 +154,7 @@ public class StatusUpdater extends VmDefUpdater { "displayPasswordSerial").getAsInt() == -1)) { return; } - vmStub.updateStatus(vmDef.get(), from -> { + vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { status.addProperty("displayPasswordSerial", -1); @@ -183,12 +183,11 @@ public class StatusUpdater extends VmDefUpdater { if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - vmStub.updateStatus(vmDef, from -> { - JsonObject status = from.statusJson(); + vmStub.updateStatus(from -> { boolean running = event.runState().vmRunning(); - updateCondition(vmDef, vmDef.statusJson(), "Running", running, - event.reason(), event.message()); - updateCondition(vmDef, vmDef.statusJson(), "Booted", + updateCondition(vmDef, "Running", running, event.reason(), + event.message()); + JsonObject status = updateCondition(vmDef, "Booted", event.runState() == RunState.BOOTED, event.reason(), event.message()); if (event.runState() == RunState.STARTING) { @@ -203,13 +202,13 @@ public class StatusUpdater extends VmDefUpdater { if (!running) { // In case console connection was still present status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", false, - "VmStopped", "The VM is not running"); + updateCondition(from, "ConsoleConnected", false, "VmStopped", + "The VM is not running"); // In case we had an irregular shutdown status.remove("osinfo"); - updateCondition(vmDef, vmDef.statusJson(), "VmopAgentConnected", - false, "VmStopped", "The VM is not running"); + updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", + "The VM is not running"); } return status; }); @@ -340,10 +339,8 @@ public class StatusUpdater extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - updateCondition(vmDef, status, "VmopAgentConnected", + return updateCondition(vmDef, "VmopAgentConnected", true, "VmopAgentStarted", "The VM operator agent is running"); - 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 f04b478..50017c1 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 @@ -31,6 +31,7 @@ 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.K8sGenericStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jgrapes.core.Channel; @@ -109,17 +110,21 @@ public class VmDefUpdater extends Component { } /** - * Update condition. + * Update condition. The `from` VM definition is used to determine the + * observed generation and the current status. This method is intended + * to be called in the function passed to + * {@link K8sGenericStub#updateStatus}. * * @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 * @param message the message + * @return the updated status */ - protected void updateCondition(VmDefinition from, JsonObject status, - String type, boolean state, String reason, String message) { + protected JsonObject updateCondition(VmDefinition from, String type, + boolean state, String reason, String message) { + JsonObject status = from.statusJson(); // Optimize, as we can get this several times var current = status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond) @@ -127,7 +132,7 @@ public class VmDefUpdater extends Component { .findFirst() .map(cond -> "True".equals(cond.get("status").getAsString())); if (current.isPresent() && current.get() == state) { - return; + return status; } // Do update @@ -150,5 +155,6 @@ public class VmDefUpdater extends Component { newConds.addAll(toReplace); status.add("conditions", apiClient.getJSON().getGson().toJsonTree(newConds)); + return status; } } From 4a242c46570fd95346c5bb14cb5778950e3f8536 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 17:12:02 +0100 Subject: [PATCH 26/29] Clear logged in user on startup and shutdown. --- .../vmoperator/runner/qemu/StatusUpdater.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 b4608b3..1bea80f 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 @@ -114,8 +114,15 @@ public class StatusUpdater extends VmDefUpdater { vmStub = VmDefinitionStub.get(apiClient, new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, vmName); - vmStub.model().ifPresent(model -> { - observedGeneration = model.getMetadata().getGeneration(); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { + return; + } + observedGeneration = vmDef.getMetadata().getGeneration(); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove("loggedInUser"); + return status; }); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -197,6 +204,7 @@ public class StatusUpdater extends VmDefUpdater { } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); + status.remove("loggedInUser"); } if (!running) { From e822d472f9028a50134d55944db09acb4923bb44 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 17:44:52 +0100 Subject: [PATCH 27/29] Optimize status update. --- .../vmoperator/common/K8sGenericStub.java | 65 +++++++++++++++---- .../vmoperator/runner/qemu/StatusUpdater.java | 17 ++--- 2 files changed, 61 insertions(+), 21 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 2af4d1b..b8f1992 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,7 @@ public class K8sGenericStub updateStatus(Function updater, O current, + int retries) throws ApiException { + while (true) { + try { + if (current == null) { + current = api.get(namespace, name) + .throwsApiException().getObject(); + } + return updateStatus(current, updater); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + // Get current version for new attempt + current = null; + } + } + } + /** * Gets the object and updates the status. In case of conflict, retries * up to `retries` times. @@ -218,17 +251,23 @@ public class K8sGenericStub updateStatus(Function updater, int retries) throws ApiException { - while (true) { - try { - return updateStatus(api.get(namespace, name) - .throwsApiException().getObject(), updater); - } catch (ApiException e) { - if (HttpURLConnection.HTTP_CONFLICT != e.getCode() - || retries-- <= 0) { - throw e; - } - } - } + return updateStatus(updater, null, retries); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater, O current) + throws ApiException { + return updateStatus(updater, current, 16); } /** @@ -241,7 +280,7 @@ public class K8sGenericStub updateStatus(Function updater) throws ApiException { - return updateStatus(updater, 16); + return updateStatus(updater, null); } /** 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 1bea80f..f2437d3 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 @@ -153,11 +153,13 @@ public class StatusUpdater extends VmDefUpdater { // by a new version of the CR. So we update only if we have // a new version of the CR. There's one exception: the display // password is configured by a file, not by the CR. - var vmDef = vmStub.model(); - if (vmDef.isPresent() - && vmDef.get().metadata().getGeneration() == observedGeneration + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { + return; + } + if (vmDef.metadata().getGeneration() == observedGeneration && (event.configuration().hasDisplayPassword - || vmDef.get().statusJson().getAsJsonPrimitive( + || vmDef.statusJson().getAsJsonPrimitive( "displayPasswordSerial").getAsInt() == -1)) { return; } @@ -172,7 +174,7 @@ public class StatusUpdater extends VmDefUpdater { .forEach(cond -> cond.addProperty("observedGeneration", from.getMetadata().getGeneration())); return status; - }); + }, vmDef); } /** @@ -219,7 +221,7 @@ public class StatusUpdater extends VmDefUpdater { "The VM is not running"); } return status; - }); + }, vmDef); // Maybe stop VM if (event.runState() == RunState.TERMINATING && !event.failed() @@ -325,7 +327,6 @@ public class StatusUpdater extends VmDefUpdater { } var asGson = gson.toJsonTree( objectMapper.convertValue(event.osinfo(), Object.class)); - vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.add("osinfo", asGson); @@ -349,7 +350,7 @@ public class StatusUpdater extends VmDefUpdater { vmStub.updateStatus(from -> { return updateCondition(vmDef, "VmopAgentConnected", true, "VmopAgentStarted", "The VM operator agent is running"); - }); + }, vmDef); } /** From 41ae658e0c1c400a16f44fed7a5970abd5961120 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 21:51:33 +0100 Subject: [PATCH 28/29] Reorganize imports. --- .../jdrupes/vmoperator/common/Constants.java | 52 ++++++++++++++----- .../vmoperator/common/VmDefinition.java | 3 +- .../vmoperator/manager/runnerConfig.ftl.yaml | 2 +- .../manager/runnerLoadBalancer.ftl.yaml | 2 +- .../vmoperator/manager/runnerPod.ftl.yaml | 2 +- .../vmoperator/manager/Controller.java | 7 ++- .../manager/DisplaySecretMonitor.java | 4 +- .../manager/DisplaySecretReconciler.java | 36 ++++++------- .../vmoperator/manager/PoolMonitor.java | 8 ++- .../vmoperator/manager/Reconciler.java | 39 ++++++++++---- .../jdrupes/vmoperator/manager/VmMonitor.java | 5 +- .../vmoperator/manager/BasicTests.java | 31 +++++------ .../runner/qemu/ConsoleTracker.java | 14 ++--- .../runner/qemu/DisplayController.java | 11 ++-- .../vmoperator/runner/qemu/Runner.java | 4 +- .../vmoperator/runner/qemu/StatusUpdater.java | 19 +++---- 16 files changed, 139 insertions(+), 100 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 7a1bd1a..60afd91 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.common; +// TODO: Auto-generated Javadoc /** * Some constants. */ @@ -30,21 +31,48 @@ public class Constants { /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; - /** The Constant VM_OP_GROUP. */ - public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; + /** + * Constants related to the CRD. + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Crd { + /** The Constant GROUP. */ + public static final String GROUP = "vmoperator.jdrupes.org"; - /** The Constant VM_OP_KIND_VM. */ - public static final String VM_OP_KIND_VM = "VirtualMachine"; + /** The Constant KIND_VM. */ + public static final String KIND_VM = "VirtualMachine"; - /** The Constant VM_OP_KIND_VM_POOL. */ - public static final String VM_OP_KIND_VM_POOL = "VmPool"; + /** The Constant KIND_VM_POOL. */ + public static final String KIND_VM_POOL = "VmPool"; + } - /** The Constant COMP_DISPLAY_SECRETS. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; + /** + * Status related constants. + */ + public static class Status { + /** The Constant LOGGED_IN_USER. */ + public static final String LOGGED_IN_USER = "loggedInUser"; - /** The Constant DATA_DISPLAY_PASSWORD. */ - public static final String DATA_DISPLAY_PASSWORD = "display-password"; + /** The Constant CONSOLE_CLIENT. */ + public static final String CONSOLE_CLIENT = "consoleClient"; - /** The Constant DATA_PASSWORD_EXPIRY. */ - public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + /** The Constant CONSOLE_USER. */ + public static final String CONSOLE_USER = "consoleUser"; + } + + /** + * DisplaySecret related constants. + */ + public static class DisplaySecret { + + /** The Constant NAME. */ + public static final String NAME = "display-secret"; + + /** The Constant PASSWORD. */ + public static final String PASSWORD = "display-password"; + + /** The Constant EXPIRY. */ + public static final String EXPIRY = "password-expiry"; + + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index ec79b80..0eb1428 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -38,6 +38,7 @@ import java.util.Set; import java.util.function.Function; import java.util.logging.Logger; import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.util.DataPath; /** @@ -286,7 +287,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional consoleUser() { - return this. fromStatus("consoleUser"); + return this. fromStatus(Status.CONSOLE_USER); } /** diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index 9c4b72a..2fbeb94 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -11,7 +11,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } controller: false diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index c25d7f4..b7215a5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -11,7 +11,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } controller: false diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml index 917d790..f000c70 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -15,7 +15,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } blockOwnerDeletion: true diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 80ff0f7..f3deefa 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -29,8 +29,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.logging.Level; -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.Constants.Crd; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -194,7 +193,7 @@ public class Controller extends Component { private void patchVmDef(K8sClient client, String name, String path, Object value) throws ApiException, IOException { var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, name); // Patch running @@ -227,7 +226,7 @@ public class Controller extends Component { try { var vmDef = channel.vmDefinition(); var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); if (vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); 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 99c8a11..a254c0e 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 @@ -28,11 +28,11 @@ import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; 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 static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jgrapes.core.Channel; @@ -61,7 +61,7 @@ public class DisplaySecretMonitor context(K8sV1SecretStub.CONTEXT); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); options(options); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index bf8042a..7c5c3ad 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -37,14 +37,12 @@ import java.util.Optional; import java.util.Scanner; import java.util.logging.Logger; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -143,7 +141,7 @@ public class DisplaySecretReconciler extends Component { var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -154,9 +152,9 @@ public class DisplaySecretReconciler extends Component { // Create secret var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) + .name(vmDef.name() + "-" + DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/name", APP_NAME) - .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; @@ -169,8 +167,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, "now")); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, "now")); K8sV1SecretStub.create(channel.client(), secret); } @@ -196,7 +194,7 @@ public class DisplaySecretReconciler extends Component { // Check if access is possible if (event.loginUser() - ? !vmDef. fromStatus("loggedInUser") + ? !vmDef. fromStatus(Status.LOGGED_IN_USER) .map(u -> u.equals(event.user())).orElse(false) : !vmDef.conditionStatus("Running").orElse(false)) { return; @@ -229,11 +227,11 @@ public class DisplaySecretReconciler extends Component { private VmDefinition updateConsoleUser(PrepareConsole event, VmChannel channel) throws ApiException { var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); return vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("consoleUser", event.user()); + status.addProperty(Status.CONSOLE_USER, event.user()); return status; }).orElse(null); } @@ -243,7 +241,7 @@ public class DisplaySecretReconciler extends Component { // Look for secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -257,12 +255,12 @@ public class DisplaySecretReconciler extends Component { private boolean updatePassword(V1Secret secret, PrepareConsole event) { var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DisplaySecret.PASSWORD) != null && stillValid(expiry)) { // Fixed secret, don't touch event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + new String(secret.getData().get(DisplaySecret.PASSWORD))); return false; } @@ -277,8 +275,8 @@ public class DisplaySecretReconciler extends Component { byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, Long.toString(Instant.now().getEpochSecond() + passwordValidity))); event.setResult(password); return true; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 25fb10b..465a9ed 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -28,8 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -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.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; @@ -38,7 +37,6 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; @@ -88,7 +86,7 @@ public class PoolMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -184,7 +182,7 @@ public class PoolMonitor extends return; } var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { // TODO diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 8011e2c..e5bfaec 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -22,12 +22,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; -import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.SimpleNumber; import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; -import freemarker.template.TemplateHashModel; import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; @@ -37,21 +35,23 @@ import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -266,17 +266,14 @@ public class Reconciler extends Component { Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); model.put("cr", vmDef); - model.put("constants", - (TemplateHashModel) new DefaultObjectWrapperBuilder( - Configuration.VERSION_2_3_32) - .build().getStaticModels() - .get(Constants.class.getName())); + // Freemarker's static models don't handle nested classes. + model.put("constants", constantsMap(Constants.class)); model.put("reconciler", config); // Check if we have a display secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub .list(client, vmDef.namespace(), options) @@ -297,6 +294,30 @@ public class Reconciler extends Component { return model; } + @SuppressWarnings("PMD.EmptyCatchBlock") + private Map constantsMap(Class clazz) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map result = new HashMap<>(); + Arrays.stream(clazz.getFields()).filter(f -> { + var modifiers = f.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) + && f.getType() == String.class; + }).forEach(f -> { + try { + result.put(f.getName(), f.get(null)); + } catch (IllegalArgumentException | IllegalAccessException e) { + // Should not happen, ignore + } + }); + Arrays.stream(clazz.getClasses()).filter(c -> { + var modifiers = c.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); + }).forEach(c -> { + result.put(c.getSimpleName(), constantsMap(c)); + }); + return result; + } + private final TemplateMethodModelEx parseQuantityModel = new TemplateMethodModelEx() { @Override diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 5c1ae77..4f8ac77 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -31,8 +31,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.stream.Collectors; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -87,7 +86,7 @@ public class VmMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 4f5d7a3..03db0d2 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -12,10 +12,10 @@ import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.jdrupes.vmoperator.common.Constants; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; @@ -60,7 +60,7 @@ class BasicTests { waitForManager(); // Context for working with our CR - var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); + var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); @@ -70,7 +70,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); for (var secret : secrets) { secret.delete(); @@ -138,12 +138,11 @@ class BasicTests { List.of("name"), VM_NAME, List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS); checkProps(config.getMetadata(), toCheck); @@ -189,7 +188,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); Collection secrets = null; for (int i = 0; i < 10; i++) { secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); @@ -219,8 +218,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Mi"))); @@ -240,8 +238,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "use_as"), "system-disk")); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), @@ -262,8 +259,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Gi"))); @@ -290,13 +286,12 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/component"), APP_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS)); checkProps(pod.getSpec(), Map.of( 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 7956fa1..ddfc702 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 @@ -25,8 +25,8 @@ 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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -74,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -108,13 +108,13 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = updateCondition(from, "ConsoleConnected", true, "Connected", "Connection from " + event.clientHost()); - status.addProperty("consoleClient", event.clientHost()); + status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); return status; }); // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("ConsoleConnectionUpdate") .reason("Connection from " + event.clientHost()); K8s.createEvent(apiClient, vmStub.model().get(), evt); @@ -142,13 +142,13 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = updateCondition(from, "ConsoleConnected", false, "Disconnected", event.clientHost() + " has disconnected"); - status.addProperty("consoleClient", ""); + status.addProperty(Status.CONSOLE_CLIENT, ""); return status; }); // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.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/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 39f71d5..d301aac 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -24,8 +24,7 @@ import java.nio.file.Path; import java.util.Objects; import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -64,7 +63,7 @@ public class DisplayController extends Component { public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD))); + fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); } /** @@ -115,7 +114,7 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) { + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { configurePassword(); } } @@ -130,7 +129,7 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - return readFromFile(DATA_DISPLAY_PASSWORD).map(password -> { + return readFromFile(DisplaySecret.PASSWORD).map(password -> { if (Objects.equals(this.currentPassword, password)) { return true; } @@ -143,7 +142,7 @@ public class DisplayController extends Component { } private void setPasswordExpiry() { - readFromFile(DATA_PASSWORD_EXPIRY).ifPresent(expiry -> { + readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> { logger.fine(() -> "Updating expiry time to " + expiry); fire( new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index f64af2d..a01618d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -56,7 +56,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -312,7 +312,7 @@ public class Runner extends Component { // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath = configDir.resolve(DATA_DISPLAY_PASSWORD); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) 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 f2437d3..3323a7e 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,8 +33,8 @@ import java.io.IOException; import java.math.BigDecimal; 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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -112,7 +112,7 @@ public class StatusUpdater extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); var vmDef = vmStub.model().orElse(null); if (vmDef == null) { @@ -121,7 +121,7 @@ public class StatusUpdater extends VmDefUpdater { observedGeneration = vmDef.getMetadata().getGeneration(); vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.remove("loggedInUser"); + status.remove(Status.LOGGED_IN_USER); return status; }); } catch (ApiException e) { @@ -206,12 +206,12 @@ public class StatusUpdater extends VmDefUpdater { } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); - status.remove("loggedInUser"); + status.remove(Status.LOGGED_IN_USER); } if (!running) { // In case console connection was still present - status.addProperty("consoleClient", ""); + status.addProperty(Status.CONSOLE_CLIENT, ""); updateCondition(from, "ConsoleConnected", false, "VmStopped", "The VM is not running"); @@ -239,7 +239,7 @@ public class StatusUpdater extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("StatusUpdate").reason(event.reason()) .note(event.message()); K8s.createEvent(apiClient, vmDef, evt); @@ -363,7 +363,8 @@ public class StatusUpdater extends VmDefUpdater { throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("loggedInUser", event.triggering().user()); + status.addProperty(Status.LOGGED_IN_USER, + event.triggering().user()); return status; }); } @@ -378,7 +379,7 @@ public class StatusUpdater extends VmDefUpdater { throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.remove("loggedInUser"); + status.remove(Status.LOGGED_IN_USER); return status; }); } From f8cc26e6576d919e7dd1768e761e6556b1e819f1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Mar 2025 22:51:51 +0100 Subject: [PATCH 29/29] Define some more constants. --- .../jdrupes/vmoperator/common/Constants.java | 16 +++++++++++++ .../vmoperator/common/VmDefinition.java | 8 +++---- .../vmoperator/manager/Controller.java | 3 ++- .../vmoperator/manager/PoolMonitor.java | 3 ++- .../vmoperator/runner/qemu/StatusUpdater.java | 24 +++++++++---------- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 9 +++---- 6 files changed, 41 insertions(+), 22 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 60afd91..71c8cf3 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -50,6 +50,19 @@ public class Constants { * Status related constants. */ public static class Status { + /** The Constant CPUS. */ + public static final String CPUS = "cpus"; + + /** The Constant RAM. */ + public static final String RAM = "ram"; + + /** The Constant OSINFO. */ + public static final String OSINFO = "osinfo"; + + /** The Constant DISPLAY_PASSWORD_SERIAL. */ + public static final String DISPLAY_PASSWORD_SERIAL + = "displayPasswordSerial"; + /** The Constant LOGGED_IN_USER. */ public static final String LOGGED_IN_USER = "loggedInUser"; @@ -58,6 +71,9 @@ public class Constants { /** The Constant CONSOLE_USER. */ public static final String CONSOLE_USER = "consoleUser"; + + /** The Constant ASSIGNMENT. */ + public static final String ASSIGNMENT = "assignment"; } /** diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index 0eb1428..a42d2d4 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -220,7 +220,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignedFrom() { - return fromStatus("assignment", "pool"); + return fromStatus(Status.ASSIGNMENT, "pool"); } /** @@ -229,7 +229,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignedTo() { - return fromStatus("assignment", "user"); + return fromStatus(Status.ASSIGNMENT, "user"); } /** @@ -238,7 +238,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignmentLastUsed() { - return this. fromStatus("assignment", "lastUsed") + return this. fromStatus(Status.ASSIGNMENT, "lastUsed") .map(Instant::parse); } @@ -389,7 +389,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional displayPasswordSerial() { - return this. fromStatus("displayPasswordSerial") + return this. fromStatus(Status.DISPLAY_PASSWORD_SERIAL) .map(Number::longValue); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index f3deefa..b61b26a 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -30,6 +30,7 @@ import java.nio.file.Path; import java.time.Instant; import java.util.logging.Level; import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -230,7 +231,7 @@ public class Controller extends Component { vmDef.namespace(), vmDef.name()); if (vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to("assignment"); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); assignment.set("pool", event.usedPool()); assignment.set("user", event.toUser()); assignment.set("lastUsed", Instant.now().toString()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 465a9ed..1ea15e1 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; @@ -187,7 +188,7 @@ public class PoolMonitor extends vmStub.updateStatus(from -> { // TODO JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to("assignment"); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); assignment.set("lastUsed", ccChange.get().toString()); 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 3323a7e..36a63c1 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 @@ -160,13 +160,13 @@ public class StatusUpdater extends VmDefUpdater { if (vmDef.metadata().getGeneration() == observedGeneration && (event.configuration().hasDisplayPassword || vmDef.statusJson().getAsJsonPrimitive( - "displayPasswordSerial").getAsInt() == -1)) { + Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) { return; } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { - status.addProperty("displayPasswordSerial", -1); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); } status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond).filter(cond -> "Running" @@ -200,12 +200,12 @@ public class StatusUpdater extends VmDefUpdater { event.runState() == RunState.BOOTED, event.reason(), event.message()); if (event.runState() == RunState.STARTING) { - status.addProperty("ram", GsonPtr.to(from.data()) + status.addProperty(Status.RAM, GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); - status.addProperty("cpus", 1); + status.addProperty(Status.CPUS, 1); } else if (event.runState() == RunState.STOPPED) { - status.addProperty("ram", "0"); - status.addProperty("cpus", 0); + status.addProperty(Status.RAM, "0"); + status.addProperty(Status.CPUS, 0); status.remove(Status.LOGGED_IN_USER); } @@ -216,7 +216,7 @@ public class StatusUpdater extends VmDefUpdater { "The VM is not running"); // In case we had an irregular shutdown - status.remove("osinfo"); + status.remove(Status.OSINFO); updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", "The VM is not running"); } @@ -258,7 +258,7 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("ram", + status.addProperty(Status.RAM, new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) .toSuffixedString()); return status; @@ -278,7 +278,7 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("cpus", event.usedCpus().size()); + status.addProperty(Status.CPUS, event.usedCpus().size()); return status; }); } @@ -297,8 +297,8 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("displayPasswordSerial", - status.get("displayPasswordSerial").getAsLong() + 1); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, + status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); return status; }); } @@ -329,7 +329,7 @@ public class StatusUpdater extends VmDefUpdater { objectMapper.convertValue(event.osinfo(), Object.class)); vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.add("osinfo", asGson); + status.add(Status.OSINFO, asGson); return status; }); diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 10b4f48..6d3891d 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; @@ -243,8 +244,8 @@ public class VmMgmt extends FreeMarkerConlet { DataPath. get(vmSpec, "currentRam").orElse("0")).getNumber() .toBigInteger()); var status = DataPath.deepCopy(vmDef.status()); - status.put("ram", Quantity.fromString( - DataPath. get(status, "ram").orElse("0")).getNumber() + status.put(Status.RAM, Quantity.fromString( + DataPath. get(status, Status.RAM).orElse("0")).getNumber() .toBigInteger()); // Build result @@ -383,10 +384,10 @@ public class VmMgmt extends FreeMarkerConlet { Summary summary = new Summary(); for (var vmDef : channelTracker.associated()) { summary.totalVms += 1; - summary.usedCpus += vmDef. fromStatus("cpus") + summary.usedCpus += vmDef. fromStatus(Status.CPUS) .map(Number::intValue).orElse(0); summary.usedRam = summary.usedRam - .add(vmDef. fromStatus("ram") + .add(vmDef. fromStatus(Status.RAM) .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) .orElse(BigInteger.ZERO)); if (vmDef.conditionStatus("Running").orElse(false)) {