Add guest agent client and retrieve OS info.

This commit is contained in:
Michael Lipp 2025-02-10 22:24:10 +01:00
parent 1fc26647b6
commit 5078001f4b
14 changed files with 592 additions and 7 deletions

View file

@ -1491,6 +1491,10 @@ spec:
by the runner if password protection is not enabled.
type: integer
default: 0
osinfo:
description: Copy of the OS info provided by the guest agent.
type: object
x-kubernetes-preserve-unknown-fields: true
assignment:
description: >-
The assignment of this VM to a a particular user.

View file

@ -5,9 +5,7 @@ metadata:
name: test-vm
spec:
image:
repository: docker-registry.lan.mnl.de
path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine
version: latest
source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
pullPolicy: Always
permissions:
@ -34,8 +32,9 @@ spec:
currentCpus: 4
networks:
- tap:
mac: "02:16:3e:33:58:10"
# No bridge on test cluster
- user: {}
disks:
- volumeClaimTemplate:
metadata:

View file

@ -67,6 +67,9 @@ 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;
@ -341,6 +344,7 @@ 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(

View file

@ -0,0 +1,254 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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.util.LinkedList;
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.MonitorReady;
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.
*
* 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 {
private static ObjectMapper mapper = new ObjectMapper();
private EventPipeline rep;
private Path socketPath;
private SocketIOChannel gaChannel;
private final Queue<QmpCommand> executing = new LinkedList<>();
/**
* Instantiates a new guest agent client.
*
* @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 powerdownTimeout
*/
/* default */ void configure(Path socketPath) {
this.socketPath = socketPath;
}
/**
* Handle the start event.
*
* @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);
}
/**
* 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 {
logger.fine(() -> "guest agent(in): " + line);
try {
var response = mapper.readValue(line, ObjectNode.class);
if (response.has("QMP")) {
rep.fire(new MonitorReady());
return;
}
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));
if (executed instanceof QmpGuestGetOsinfo) {
rep.fire(new OsinfoEvent(response.get("return")));
}
}
} catch (JsonProcessingException e) {
throw new IOException(e);
}
}
/**
* 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" })
public void onGuestAgentCommand(GuestAgentCommand event) {
var command = event.command();
logger.fine(() -> "guest agent(out): " + command.toString());
String asText;
try {
asText = command.asText();
} catch (JsonProcessingException e) {
logger.log(Level.SEVERE, e,
() -> "Cannot serialize Json: " + e.getMessage());
return;
}
synchronized (executing) {
gaChannel.associated(Writer.class).ifPresent(writer -> {
try {
executing.add(command);
writer.append(asText).append('\n').flush();
} catch (IOException e) {
// Cannot happen, but...
logger.log(Level.WARNING, e, e::getMessage);
}
});
}
}
}

View file

@ -218,6 +218,7 @@ public class Runner extends Component {
private CommandDefinition cloudInitImgDefinition;
private CommandDefinition qemuDefinition;
private final QemuMonitor qemuMonitor;
private final GuestAgentClient guestAgentClient;
private Integer resetCounter;
private RunState state = RunState.INITIALIZING;
@ -275,6 +276,7 @@ public class Runner extends Component {
attach(new ProcessManager(channel()));
attach(new SocketConnector(channel()));
attach(qemuMonitor = new QemuMonitor(channel(), configDir));
attach(guestAgentClient = new GuestAgentClient(channel()));
attach(new StatusUpdater(channel()));
attach(new YamlConfigurationStore(channel(), configFile, false));
fire(new WatchFile(configFile.toPath()));
@ -349,6 +351,7 @@ public class Runner extends Component {
// Forward some values to child components
qemuMonitor.configure(config.monitorSocket,
config.vm.powerdownTimeout);
guestAgentClient.configure(config.guestAgentSocket);
} catch (IllegalArgumentException | IOException | TemplateException e) {
logger.log(Level.SEVERE, e, () -> "Invalid configuration: "
+ e.getMessage());

View file

@ -18,12 +18,16 @@
package org.jdrupes.vmoperator.runner.qemu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import io.kubernetes.client.apimachinery.GroupVersionKind;
import io.kubernetes.client.custom.Quantity;
import io.kubernetes.client.custom.Quantity.Format;
import io.kubernetes.client.custom.V1Patch;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.JSON;
import io.kubernetes.client.openapi.models.EventsV1Event;
import java.io.IOException;
import java.math.BigDecimal;
@ -40,6 +44,7 @@ import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged;
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
@ -55,6 +60,12 @@ import org.jgrapes.core.events.Start;
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class StatusUpdater extends VmDefUpdater {
@SuppressWarnings("PMD.FieldNamingConventions")
private static final Gson gson = new JSON().getGson();
@SuppressWarnings("PMD.FieldNamingConventions")
private static final ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule());
private static final Set<RunState> RUNNING_STATES
= Set.of(RunState.RUNNING, RunState.TERMINATING);
@ -286,4 +297,26 @@ public class StatusUpdater extends VmDefUpdater {
public void onShutdown(ShutdownEvent event) throws ApiException {
shutdownByGuest = event.byGuest();
}
/**
* On osinfo.
*
* @param event the event
* @throws ApiException
*/
@Handler
public void onOsinfo(OsinfoEvent event) throws ApiException {
if (vmStub == null) {
return;
}
var asGson = gson.toJsonTree(
objectMapper.convertValue(event.osinfo(), Object.class));
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.add("osinfo", asGson);
return status;
});
}
}

View file

@ -0,0 +1,41 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* A {@link QmpCommand} that pings the guest agent.
*/
public class QmpGuestGetOsinfo extends QmpCommand {
@Override
public JsonNode toJson() {
ObjectNode cmd = mapper.createObjectNode();
cmd.put("execute", "guest-get-osinfo");
return cmd;
}
@Override
public String toString() {
return "QmpGuestGetOsinfo()";
}
}

View file

@ -0,0 +1,41 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* A {@link QmpCommand} that requests the guest info.
*/
public class QmpGuestInfo extends QmpCommand {
@Override
public JsonNode toJson() {
ObjectNode cmd = mapper.createObjectNode();
cmd.put("execute", "guest-info");
return cmd;
}
@Override
public String toString() {
return "QmpGuestInfo()";
}
}

View file

@ -0,0 +1,41 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* A {@link QmpCommand} that pings the guest agent.
*/
public class QmpGuestPing extends QmpCommand {
@Override
public JsonNode toJson() {
ObjectNode cmd = mapper.createObjectNode();
cmd.put("execute", "guest-ping");
return cmd;
}
@Override
public String toString() {
return "QmpGuestPing()";
}
}

View file

@ -0,0 +1,63 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.events;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Components;
import org.jgrapes.core.Event;
/**
* An {@link Event} that causes some component to send a QMP
* command to the guest agent process.
*/
public class GuestAgentCommand extends Event<Void> {
private final QmpCommand command;
/**
* Instantiates a new exec qmp command.
*
* @param command the command
*/
public GuestAgentCommand(QmpCommand command) {
this.command = command;
}
/**
* Gets the command.
*
* @return the command
*/
public QmpCommand command() {
return command;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append(Components.objectName(this))
.append(" [").append(command);
if (channels() != null) {
builder.append(", channels=").append(Channel.toString(channels()));
}
builder.append(']');
return builder.toString();
}
}

View file

@ -35,7 +35,7 @@ public class MonitorEvent extends Event<Void> {
*/
public enum Kind {
READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN,
SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED
SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE
}
private final Kind kind;
@ -72,6 +72,9 @@ public class MonitorEvent extends Event<Void> {
case SPICE_DISCONNECTED:
return Optional.of(new SpiceDisconnectedEvent(kind,
response.get(EVENT_DATA)));
case VSERPORT_CHANGE:
return Optional.of(new VserportChangeEvent(kind,
response.get(EVENT_DATA)));
default:
return Optional
.of(new MonitorEvent(kind, response.get(EVENT_DATA)));

View file

@ -0,0 +1,43 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.events;
import com.fasterxml.jackson.databind.JsonNode;
import org.jgrapes.core.Event;
/**
* Signals information about the guest OS.
*/
public class OsinfoEvent extends Event<Void> {
private final JsonNode osinfo;
/**
* Instantiates a new osinfo event.
*
* @param data the data
*/
public OsinfoEvent(JsonNode data) {
osinfo = data;
}
public JsonNode osinfo() {
return osinfo;
}
}

View file

@ -0,0 +1,56 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.events;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Signals a virtual serial port's open state change.
*/
public class VserportChangeEvent extends MonitorEvent {
/**
* Initializes a new instance.
*
* @param kind the kind
* @param data the data
*/
public VserportChangeEvent(Kind kind, JsonNode data) {
super(kind, data);
}
/**
* Return the channel's id.
*
* @return the string
*/
@SuppressWarnings("PMD.ShortMethodName")
public String id() {
return data().get("id").asText();
}
/**
* Returns the open state of the port.
*
* @return true, if is open
*/
public boolean isOpen() {
return Boolean.parseBoolean(data().get("open").asText());
}
}

View file

@ -122,7 +122,7 @@
# Best explanation found:
# https://fedoraproject.org/wiki/Features/VirtioSerial
- [ "-device", "virtio-serial-pci,id=virtio-serial0" ]
# - Guest agent serial connection
# - Guest agent serial connection. MUST have id "channel0"!
- [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\
chardev=guest-agent-socket" ]
- [ "-chardev","socket,id=guest-agent-socket,\