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/66] 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/66] 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/66] 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/66] 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/66] 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 5b8b47f95cb80bc469db18b2be7f166bfc95a5b8 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 11:47:13 +0100 Subject: [PATCH 06/66] Add some metadata to make bing happy. --- dev-example/test-vm.tpl.yaml | 2 +- webpages/vm-operator/admin-gui.md | 4 ++++ webpages/vm-operator/controller.md | 3 +++ webpages/vm-operator/index.md | 7 +++++-- webpages/vm-operator/manager.md | 3 +++ webpages/vm-operator/runner.md | 4 ++++ webpages/vm-operator/upgrading.md | 2 ++ webpages/vm-operator/user-gui.md | 4 ++++ 8 files changed, 26 insertions(+), 3 deletions(-) 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/webpages/vm-operator/admin-gui.md b/webpages/vm-operator/admin-gui.md index 4bfa3d3..4b7f5b2 100644 --- a/webpages/vm-operator/admin-gui.md +++ b/webpages/vm-operator/admin-gui.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: Administrator View — Provides an overview of running VMs" +description: >- + Information about the administrator view of the VM-Operator, which provides + an overview of the defined VMs, their state and resource consumptions and + actions for starting, stopping and accessing the VMs. layout: vm-operator --- diff --git a/webpages/vm-operator/controller.md b/webpages/vm-operator/controller.md index cc6a274..e20263f 100644 --- a/webpages/vm-operator/controller.md +++ b/webpages/vm-operator/controller.md @@ -1,5 +1,8 @@ --- title: "VM-Operator: Controller — Reconciles the VM CRs" +description: >- + Information about the VM Operator's controller component its + configuration options and the CRD used to define VMs. layout: vm-operator --- diff --git a/webpages/vm-operator/index.md b/webpages/vm-operator/index.md index baf8e20..5cd2d58 100644 --- a/webpages/vm-operator/index.md +++ b/webpages/vm-operator/index.md @@ -1,6 +1,9 @@ --- -title: Run VMs on Kubernetes using Qemu/KVM and SPICE -description: A solution for running VMs on Kubernetes with a web interface for admins and users. Focuses on running Qemu/KVM virtual machines and using SPICE as display protocol. +title: "Run VMs on Kubernetes using QEMU/KVM and SPICE" +description: >- + A solution for running VMs on Kubernetes with a web interface for + admins and users. Focuses on running QEMU/KVM virtual machines and + using SPICE as display protocol. layout: vm-operator --- diff --git a/webpages/vm-operator/manager.md b/webpages/vm-operator/manager.md index c1965f1..ee971c1 100644 --- a/webpages/vm-operator/manager.md +++ b/webpages/vm-operator/manager.md @@ -1,5 +1,8 @@ --- title: "VM-Operator: The Manager — Provides the controller and a web user interface" +description: >- + Information about the installation and configuration of the + VM Operator. layout: vm-operator --- diff --git a/webpages/vm-operator/runner.md b/webpages/vm-operator/runner.md index c72793d..a6a744d 100644 --- a/webpages/vm-operator/runner.md +++ b/webpages/vm-operator/runner.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: The Runner — Starts and monitors a VM" +description: >- + Description of the VM Operator's runner component which starts + QEMU and thus the VM, optionally together with a TPM, in a + kubenernetes pod and monitors everything. layout: vm-operator --- diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 77cacad..b987298 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -1,5 +1,7 @@ --- title: "VM-Operator: Upgrading — Issues to watch out for" +description: >- + Information about issues to watch out for when upgrading the VM-Operator. layout: vm-operator --- diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 0439db2..0d16113 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: User View — Allows users to manage their own VMs" +description: >- + Information about the user view of the VM-Operator, which allows users + to access and optionally manage the VMs for which they have the + respective permissions. layout: vm-operator --- From 558f4d96c9aee5186d85130d85345712cdaef5f1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 12:00:27 +0100 Subject: [PATCH 07/66] Add pagefind. --- webpages/_layouts/vm-operator.html | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 30e6407..e779711 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -1,5 +1,5 @@ - + @@ -11,10 +11,31 @@ - {% seo %} + + + + {% seo %}
+
@@ -68,4 +89,4 @@ {% include matomo.html %} - + From f236b376ae27d4c6c8f8a40ad9d8681ef0730c90 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 12:05:55 +0100 Subject: [PATCH 08/66] Back to testing. --- dev-example/test-vm.tpl.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 260341e..50031bb 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:feature-auto-login + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing pullPolicy: Always permissions: From e3b5f5a04dcd92cfbf1b34af605cf6f77534c085 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 11:58:13 +0100 Subject: [PATCH 09/66] 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 10/66] 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 ddab466fd05a8d168f5b38dd3716a4ccdb898985 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 14:03:49 +0100 Subject: [PATCH 11/66] Restrict pagefind search to project. --- webpages/_layouts/vm-operator.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index e779711..4456d20 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -11,11 +11,12 @@ - - + + + + +

Hosted on GitHub Pages — TermsPrivacy — Theme derived from minimal

From d7af1f5d068ab4f8283e109220b16ace7f88c322 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:23:02 +0100 Subject: [PATCH 39/66] Fix footer. --- webpages/_layouts/vm-operator.html | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 5d780ef..1c3bad2 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -82,15 +82,17 @@
- - - - - -

Hosted on GitHub Pages — Terms - — Privacy - — Theme derived from minimal

From 05d53c58b16f5ac60842012d0fc0c662bdd03cbd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:28:08 +0100 Subject: [PATCH 40/66] Fix footer. --- webpages/_layouts/vm-operator.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 1c3bad2..966b268 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -88,7 +88,7 @@ — Privacy — Theme derived from minimal

-
- -
From 687a050ec446441ba6e28e1e56f19d2473b9641c Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:55:40 +0100 Subject: [PATCH 42/66] Generate sitemap. --- webpages/Gemfile | 1 + webpages/_config.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/webpages/Gemfile b/webpages/Gemfile index ecbbb7d..3e965ca 100644 --- a/webpages/Gemfile +++ b/webpages/Gemfile @@ -2,4 +2,5 @@ source 'https://rubygems.org' # gem 'github-pages', group: :jekyll_plugins gem "jekyll", "~> 4.0" gem "jekyll-seo-tag" +gem 'jekyll-sitemap' gem 'webrick', '~> 1.3', '>= 1.3.1' diff --git a/webpages/_config.yml b/webpages/_config.yml index bc830a3..a2ee1a3 100644 --- a/webpages/_config.yml +++ b/webpages/_config.yml @@ -1,5 +1,6 @@ plugins: - jekyll-seo-tag + - jekyll-sitemap url: "https://jdrupes.org" From 07fb07a6a4c4c334cad516282a0ffeac331dc17d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:57:26 +0100 Subject: [PATCH 43/66] Javadoc is hosted on main site only. --- webpages/_layouts/vm-operator.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 8be6f81..820aeb6 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -68,7 +68,7 @@
  • For Users

  • Upgrading

    -

    Javadoc

    +

    Javadoc

    From 3a94602a0d825cda0045401e270f34e1ab183b61 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:02:46 +0100 Subject: [PATCH 44/66] Add sitemap. --- webpages/robots.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 webpages/robots.txt diff --git a/webpages/robots.txt b/webpages/robots.txt new file mode 100644 index 0000000..457a45d --- /dev/null +++ b/webpages/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: sitemap.xml From f6338758d8b5abfaa125cc3e07698eb6eacf8b29 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:13:03 +0100 Subject: [PATCH 45/66] Sitemap property must be an absolute url. --- webpages/.readthedocs.yaml | 1 + webpages/robots-readthedocs.txt | 3 +++ webpages/robots.txt | 3 --- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 webpages/robots-readthedocs.txt delete mode 100644 webpages/robots.txt diff --git a/webpages/.readthedocs.yaml b/webpages/.readthedocs.yaml index 15aece6..2eac996 100644 --- a/webpages/.readthedocs.yaml +++ b/webpages/.readthedocs.yaml @@ -14,4 +14,5 @@ build: - cd webpages && bundle install # Build the site and save generated files into Read the Docs directory - cd webpages && jekyll build --destination $READTHEDOCS_OUTPUT/html + - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html \ No newline at end of file diff --git a/webpages/robots-readthedocs.txt b/webpages/robots-readthedocs.txt new file mode 100644 index 0000000..90e0f33 --- /dev/null +++ b/webpages/robots-readthedocs.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://kubernetes-vm-operator.readthedocs.io/sitemap.xml diff --git a/webpages/robots.txt b/webpages/robots.txt deleted file mode 100644 index 457a45d..0000000 --- a/webpages/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -User-agent: * -Allow: / -Sitemap: sitemap.xml From 199cd8185ea66681ba2617f1ef553dc3eae54cc8 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:15:38 +0100 Subject: [PATCH 46/66] Fix robots.txt "generation". --- webpages/.readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/.readthedocs.yaml b/webpages/.readthedocs.yaml index 2eac996..546c09f 100644 --- a/webpages/.readthedocs.yaml +++ b/webpages/.readthedocs.yaml @@ -14,5 +14,5 @@ build: - cd webpages && bundle install # Build the site and save generated files into Read the Docs directory - cd webpages && jekyll build --destination $READTHEDOCS_OUTPUT/html - - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html + - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html/robots.txt \ No newline at end of file From 60349bca7881b4c08fc034aa6934d5eff078b882 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:45:38 +0100 Subject: [PATCH 47/66] Shorten title to make bing happy. --- webpages/vm-operator/manager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/vm-operator/manager.md b/webpages/vm-operator/manager.md index ee971c1..d283484 100644 --- a/webpages/vm-operator/manager.md +++ b/webpages/vm-operator/manager.md @@ -1,5 +1,5 @@ --- -title: "VM-Operator: The Manager — Provides the controller and a web user interface" +title: "VM-Operator: The Manager — Provides the controller and a Web UI" description: >- Information about the installation and configuration of the VM Operator. From 0e28bcd038bc529a5ee8be6f228449bfd33426bb Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 18:48:13 +0100 Subject: [PATCH 48/66] Change (main) site. --- webpages/_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_config.yml b/webpages/_config.yml index a2ee1a3..1ebd835 100644 --- a/webpages/_config.yml +++ b/webpages/_config.yml @@ -2,7 +2,7 @@ plugins: - jekyll-seo-tag - jekyll-sitemap -url: "https://jdrupes.org" +url: "https://vm-operator.jdrupes.org" author: Michael N. Lipp From fd0bcc93074418fec8ba33f962210a856edd7f53 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:10:42 +0100 Subject: [PATCH 49/66] Move main site to vm-operator.jdrupes.org. --- misc/javadoc.bottom.txt | 7 ++++--- webpages/_includes/matomo.html | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index abf54f3..631d63f 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -16,18 +16,19 @@ var _paq = _paq || []; /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ _paq.push(["setDocumentTitle", document.domain + "/" + document.title]); - _paq.push(["setCookieDomain", "*.jdrupes.org"]); + _paq.push(["setCookieDomain", "*.mnlipp.github.io"]); + _paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]); _paq.push(['disableCookies']); _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { var u="//jdrupes.org/"; _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', '15']); + _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); - +
    \ No newline at end of file diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo.html index 3a93186..75978bc 100644 --- a/webpages/_includes/matomo.html +++ b/webpages/_includes/matomo.html @@ -4,20 +4,20 @@ /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ _paq.push(["setDocumentTitle", document.domain + "/" + document.title]); _paq.push(["setCookieDomain", "*.mnlipp.github.io"]); - _paq.push(["setDomains", ["*.mnlipp.github.io"]]); + _paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]); _paq.push(['disableCookies']); _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { var u="//piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', '14']); + _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); From a0dfd2519226e6ac479d0cbe462a9ac5bc9a65fd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:29:56 +0100 Subject: [PATCH 50/66] Add robots.txt. --- webpages/robots.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 webpages/robots.txt diff --git a/webpages/robots.txt b/webpages/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/webpages/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / From 4965845f3da5170ce813aa8430c6364d3c85bf2b Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:44:39 +0100 Subject: [PATCH 51/66] Move robots.txt. --- webpages/vm-operator/robots.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 webpages/vm-operator/robots.txt diff --git a/webpages/vm-operator/robots.txt b/webpages/vm-operator/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/webpages/vm-operator/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / From d8cff8b9425e449255dc15db3e5496ffb0a8fef4 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:06:26 +0100 Subject: [PATCH 52/66] Track vm-operator separately. --- buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle | 1 + .../{matomo.html => matomo-vm-operator.jdrupes.org.html} | 0 webpages/_layouts/vm-operator.html | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename webpages/_includes/{matomo.html => matomo-vm-operator.jdrupes.org.html} (100%) diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 5eed550..0b6e68f 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -124,6 +124,7 @@ gitPublish { branch = 'main' contents { from("${rootProject.projectDir}/webpages") { + include '_includes/matomo-vm-operator.jdrupes.org.html' include '_layouts/vm-operator.html' include 'vm-operator/**' } diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo-vm-operator.jdrupes.org.html similarity index 100% rename from webpages/_includes/matomo.html rename to webpages/_includes/matomo-vm-operator.jdrupes.org.html diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 820aeb6..2e2e444 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -104,7 +104,7 @@ - {% include matomo.html %} + {% include matomo-vm-operator.jdrupes.org.html %} From d6e2a92fe8a3f5578e606d8eb9956e07f967c67d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:20:25 +0100 Subject: [PATCH 53/66] Fix url. --- misc/javadoc.bottom.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index 631d63f..c858345 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -22,7 +22,7 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//jdrupes.org/"; + var u="//piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; From 7670857d0a48bf8fd4c6faa89e36dba0bfa46665 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:50:24 +0100 Subject: [PATCH 54/66] Separate sites. --- .github/workflows/jekyll.yml | 68 +++++++++++++++++++ ...-operator.jdrupes.org.html => matomo.html} | 0 webpages/_layouts/vm-operator.html | 4 +- webpages/robots.txt | 2 - 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/jekyll.yml rename webpages/_includes/{matomo-vm-operator.jdrupes.org.html => matomo.html} (100%) delete mode 100644 webpages/robots.txt diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml new file mode 100644 index 0000000..57d3802 --- /dev/null +++ b/.github/workflows/jekyll.yml @@ -0,0 +1,68 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between +# the run in-progress and latest queued. However, do NOT cancel +# in-progress runs as we want to allow these production deployments +# to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + cache-version: 0 # Increment this number if you need to re-download cached gems + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + # Outputs to the './_site' directory by default + run: bundle exec jekyll build + env: + JEKYLL_ENV: production + - name: Index pagefind + run: npx pagefind --source "_site/vm-operator" + - name: Upload artifact + # Automatically uploads an artifact from the './_site' directory by default + uses: actions/upload-pages-artifact@v3 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/webpages/_includes/matomo-vm-operator.jdrupes.org.html b/webpages/_includes/matomo.html similarity index 100% rename from webpages/_includes/matomo-vm-operator.jdrupes.org.html rename to webpages/_includes/matomo.html diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 2e2e444..6d0df26 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -83,7 +83,7 @@
    - {% include matomo-vm-operator.jdrupes.org.html %} + {% include matomo.html %} diff --git a/webpages/robots.txt b/webpages/robots.txt deleted file mode 100644 index c2a49f4..0000000 --- a/webpages/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Allow: / From 083c6db2da1224786963e0cfe791fcf24a65f692 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:12:43 +0100 Subject: [PATCH 55/66] Build own site. --- .github/workflows/jekyll.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 57d3802..b273259 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -35,25 +35,38 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Build apidocs + run: ./gradlew apidocs - name: Setup Pages id: pages uses: actions/configure-pages@v5 - name: Build with Jekyll # Outputs to the './_site' directory by default - run: bundle exec jekyll build + run: cd webpages && bundle exec jekyll build env: JEKYLL_ENV: production + - name: Copy javadoc + run: cp -a build/javadoc webpages/_site/vm-operator/ - name: Index pagefind - run: npx pagefind --source "_site/vm-operator" + run: cd webpages && npx pagefind --source "_site/vm-operator" - name: Upload artifact # Automatically uploads an artifact from the './_site' directory by default uses: actions/upload-pages-artifact@v3 + with: + path: './webpages/_site' # Deployment job deploy: From 987f634f40cc1ab3abbade24ab5cb36b2634a8ce Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:33:22 +0100 Subject: [PATCH 56/66] Update. --- .github/workflows/jekyll.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index b273259..c7df16e 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -35,21 +35,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems - - name: Install graphviz - run: sudo apt-get install graphviz - - name: Build apidocs - run: ./gradlew apidocs - name: Setup Pages id: pages uses: actions/configure-pages@v5 @@ -58,6 +49,15 @@ jobs: run: cd webpages && bundle exec jekyll build env: JEKYLL_ENV: production + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + - name: Build apidocs + run: ./gradlew apidocs - name: Copy javadoc run: cp -a build/javadoc webpages/_site/vm-operator/ - name: Index pagefind From 30bc11917880229e14bcbd46e80935019484bfbd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:57:22 +0100 Subject: [PATCH 57/66] Update. --- .github/workflows/jekyll.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index c7df16e..58609d6 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -41,6 +41,7 @@ jobs: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems + working-directory: webpages - name: Setup Pages id: pages uses: actions/configure-pages@v5 From 214085119c180a990835e05d01902bd54396fdd0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 23:15:19 +0100 Subject: [PATCH 58/66] Add style for searching. --- webpages/stylesheets/styles.css | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/webpages/stylesheets/styles.css b/webpages/stylesheets/styles.css index 8d6b803..41fb0d0 100644 --- a/webpages/stylesheets/styles.css +++ b/webpages/stylesheets/styles.css @@ -189,6 +189,56 @@ footer { margin-bottom:5px; } +#search { + + --pagefind-ui-font: inherit; + --pagefind-ui-border-radius: 4px; + + position: absolute; + right: 1em; + top: 1em; + + .pagefind-ui__form { + width: 20em; + margin-left: auto; + + &::before { + top: calc(17px * var(--pagefind-ui-scale)); + } + } + + .pagefind-ui__search-input { + font-weight: inherit; + height: calc(48px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__search-clear { + font-weight: inherit; + height: calc(42px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__drawer { + position: absolute; + right: 0; + width: 40em; + background-color: white; + border: solid var(--pagefind-ui-border-width) var(--pagefind-ui-border); + padding: 0 1em 1em 1em; + } + + .pagefind-ui__message { + padding-top: 0; + } + + .pagefind-ui__result { + padding: 0; + } + + .pagefind-ui__result-title { + font-weight: inherit; + } +} + @media print, screen and (max-width: 960px) { div.wrapper { From 03fdabe85a777618b9e9cd69158acafde87922e8 Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Mon, 3 Mar 2025 06:50:03 +0000 Subject: [PATCH 59/66] Edit README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c989b6..e6292ec 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ # Run Qemu in Kubernetes Pods -The goal of this project is to provide easy to use and flexible components +The goal of this project is to provide orgy to use and flexible components for running Qemu based VMs in Kubernetes pods. - -See the [project's home page](https://jdrupes.org/vm-operator/) +vm-ovm +See the [project's home page](https://vm-operator.jdrupes.org/) for details. From c004265f5e8165c4a88bba79b96e132169972c46 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 3 Mar 2025 09:25:58 +0100 Subject: [PATCH 60/66] Don't merge into jdrupes.org any more. --- build.gradle | 9 +----- ...pes.vmoperator.java-doc-conventions.gradle | 31 ------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/build.gradle b/build.gradle index 8a7b571..eb8e59a 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ allprojects { } task stage { - description = 'To be executed by CI, build and update JavaDoc.' + description = 'To be executed by CI.' group = 'build' // Build everything first @@ -27,13 +27,6 @@ task stage { dependsOn subprojects.tasks.collect { tc -> tc.findByName("build") }.flatten() } - - def gitBranch = grgit.branch.current.name.replace('/', '-') - if (JavaVersion.current() == JavaVersion.VERSION_21 - && gitBranch == "main") { - // Publish JavaDoc - dependsOn gitPublishPush - } } eclipse { diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 0b6e68f..6af8fa7 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -118,34 +118,3 @@ if (System.properties['org.ajoberstar.grgit.auth.username'] == null) { System.setProperty('org.ajoberstar.grgit.auth.username', project.rootProject.properties['website.push.token'] ?: "nouser") } - -gitPublish { - repoUri = 'https://github.com/mnlipp/jdrupes.org.git' - branch = 'main' - contents { - from("${rootProject.projectDir}/webpages") { - include '_includes/matomo-vm-operator.jdrupes.org.html' - include '_layouts/vm-operator.html' - include 'vm-operator/**' - } - from("${rootProject.buildDir}/javadoc") { - into 'vm-operator/javadoc' - } - if (!findProject(':org.jdrupes.vmoperator.runner.qemu').isSnapshot - && !findProject(':org.jdrupes.vmoperator.manager').isSnapshot) { - from("${rootProject.buildDir}/javadoc") { - into 'vm-operator/latest-release/javadoc' - } - } - } - preserve { include '**/*' } - commitMessage = "Updated." -} - -gradle.projectsEvaluated { - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("build") }.flatten() - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("test") }.flatten() - tasks.gitPublishCopy.dependsOn apidocs -} From cc78c38efe650301f9bfba4d4632d781acd8a7eb Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 3 Mar 2025 10:16:05 +0100 Subject: [PATCH 61/66] Remove no longer needed sub-directory. --- .github/workflows/jekyll.yml | 4 ++-- misc/javadoc.bottom.txt | 5 +++-- webpages/{vm-operator => }/02_2_operator.png | Bin .../VM-Operator-GUI-preview.png | Bin .../{vm-operator => }/VM-Operator-GUI-view.png | Bin .../{vm-operator => }/VM-Operator-with-font.svg | 0 webpages/{vm-operator => }/VM-Operator.svg | 0 webpages/{vm-operator => }/VmAccess-preview.png | Bin webpages/_includes/matomo.html | 4 ++-- webpages/_layouts/vm-operator.html | 15 ++++++++------- webpages/{vm-operator => }/admin-gui.md | 0 webpages/{vm-operator => }/controller.md | 0 webpages/{vm-operator => }/favicon.svg | 0 webpages/{vm-operator => }/index-pic.svg | 0 webpages/index.html | 11 ----------- webpages/{vm-operator => }/index.md | 0 webpages/{vm-operator => }/manager.md | 0 webpages/{vm-operator => }/pools.md | 0 webpages/{vm-operator => }/robots.txt | 0 webpages/{vm-operator => }/runner.md | 0 webpages/{vm-operator => }/upgrading.md | 0 webpages/{vm-operator => }/user-gui.md | 0 webpages/{vm-operator => }/webgui.md | 0 23 files changed, 15 insertions(+), 24 deletions(-) rename webpages/{vm-operator => }/02_2_operator.png (100%) rename webpages/{vm-operator => }/VM-Operator-GUI-preview.png (100%) rename webpages/{vm-operator => }/VM-Operator-GUI-view.png (100%) rename webpages/{vm-operator => }/VM-Operator-with-font.svg (100%) rename webpages/{vm-operator => }/VM-Operator.svg (100%) rename webpages/{vm-operator => }/VmAccess-preview.png (100%) rename webpages/{vm-operator => }/admin-gui.md (100%) rename webpages/{vm-operator => }/controller.md (100%) rename webpages/{vm-operator => }/favicon.svg (100%) rename webpages/{vm-operator => }/index-pic.svg (100%) delete mode 100644 webpages/index.html rename webpages/{vm-operator => }/index.md (100%) rename webpages/{vm-operator => }/manager.md (100%) rename webpages/{vm-operator => }/pools.md (100%) rename webpages/{vm-operator => }/robots.txt (100%) rename webpages/{vm-operator => }/runner.md (100%) rename webpages/{vm-operator => }/upgrading.md (100%) rename webpages/{vm-operator => }/user-gui.md (100%) rename webpages/{vm-operator => }/webgui.md (100%) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 58609d6..b5acc1f 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -60,9 +60,9 @@ jobs: - name: Build apidocs run: ./gradlew apidocs - name: Copy javadoc - run: cp -a build/javadoc webpages/_site/vm-operator/ + run: cp -a build/javadoc webpages/_site/ - name: Index pagefind - run: cd webpages && npx pagefind --source "_site/vm-operator" + run: cd webpages && npx pagefind --source "_site" - name: Upload artifact # Automatically uploads an artifact from the './_site' directory by default uses: actions/upload-pages-artifact@v3 diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index c858345..dfc3373 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -22,13 +22,14 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//piwik.mnl.de/"; + var u="https://piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); - + \ No newline at end of file diff --git a/webpages/vm-operator/02_2_operator.png b/webpages/02_2_operator.png similarity index 100% rename from webpages/vm-operator/02_2_operator.png rename to webpages/02_2_operator.png diff --git a/webpages/vm-operator/VM-Operator-GUI-preview.png b/webpages/VM-Operator-GUI-preview.png similarity index 100% rename from webpages/vm-operator/VM-Operator-GUI-preview.png rename to webpages/VM-Operator-GUI-preview.png diff --git a/webpages/vm-operator/VM-Operator-GUI-view.png b/webpages/VM-Operator-GUI-view.png similarity index 100% rename from webpages/vm-operator/VM-Operator-GUI-view.png rename to webpages/VM-Operator-GUI-view.png diff --git a/webpages/vm-operator/VM-Operator-with-font.svg b/webpages/VM-Operator-with-font.svg similarity index 100% rename from webpages/vm-operator/VM-Operator-with-font.svg rename to webpages/VM-Operator-with-font.svg diff --git a/webpages/vm-operator/VM-Operator.svg b/webpages/VM-Operator.svg similarity index 100% rename from webpages/vm-operator/VM-Operator.svg rename to webpages/VM-Operator.svg diff --git a/webpages/vm-operator/VmAccess-preview.png b/webpages/VmAccess-preview.png similarity index 100% rename from webpages/vm-operator/VmAccess-preview.png rename to webpages/VmAccess-preview.png diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo.html index 75978bc..adb7c30 100644 --- a/webpages/_includes/matomo.html +++ b/webpages/_includes/matomo.html @@ -9,7 +9,7 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//piwik.mnl.de/"; + var u="https://piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; @@ -17,7 +17,7 @@ })(); diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 6d0df26..590412a 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -5,18 +5,19 @@ - - + + - - + +