001/* 002 * VM-Operator 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.vmoperator.runner.qemu; 020 021import com.fasterxml.jackson.core.JsonProcessingException; 022import com.fasterxml.jackson.databind.node.ObjectNode; 023import java.io.IOException; 024import java.time.Instant; 025import java.util.LinkedList; 026import java.util.Queue; 027import java.util.logging.Level; 028import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; 029import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; 030import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; 031import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown; 032import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; 033import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; 034import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; 035import org.jgrapes.core.Channel; 036import org.jgrapes.core.Components; 037import org.jgrapes.core.Components.Timer; 038import org.jgrapes.core.annotation.Handler; 039import org.jgrapes.core.events.Stop; 040import org.jgrapes.io.events.ProcessExited; 041 042/** 043 * A component that handles the communication with the guest agent. 044 * 045 * If the log level for this class is set to fine, the messages 046 * exchanged on the monitor socket are logged. 047 */ 048public class GuestAgentClient extends AgentConnector { 049 050 private boolean connected; 051 private Instant powerdownStartedAt; 052 private int powerdownTimeout; 053 private Timer powerdownTimer; 054 private final Queue<QmpCommand> executing = new LinkedList<>(); 055 private Stop suspendedStop; 056 057 /** 058 * Instantiates a new guest agent client. 059 * 060 * @param componentChannel the component channel 061 * @throws IOException Signals that an I/O exception has occurred. 062 */ 063 public GuestAgentClient(Channel componentChannel) throws IOException { 064 super(componentChannel); 065 } 066 067 /** 068 * When the agent has connected, request the OS information. 069 */ 070 @Override 071 protected void agentConnected() { 072 logger.fine(() -> "guest agent connected"); 073 connected = true; 074 rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); 075 } 076 077 @Override 078 protected void agentDisconnected() { 079 logger.fine(() -> "guest agent disconnected"); 080 connected = false; 081 } 082 083 /** 084 * Process agent input. 085 * 086 * @param line the line 087 * @throws IOException Signals that an I/O exception has occurred. 088 */ 089 @Override 090 protected void processInput(String line) throws IOException { 091 logger.fine(() -> "guest agent(in): " + line); 092 try { 093 var response = mapper.readValue(line, ObjectNode.class); 094 if (response.has("return") || response.has("error")) { 095 QmpCommand executed = executing.poll(); 096 logger.fine(() -> String.format("(Previous \"guest agent(in)\"" 097 + " is result from executing %s)", executed)); 098 if (executed instanceof QmpGuestGetOsinfo) { 099 var osInfo = new OsinfoEvent(response.get("return")); 100 rep().fire(osInfo); 101 } 102 } 103 } catch (JsonProcessingException e) { 104 throw new IOException(e); 105 } 106 } 107 108 /** 109 * On guest agent command. 110 * 111 * @param event the event 112 * @throws IOException Signals that an I/O exception has occurred. 113 */ 114 @Handler 115 @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", 116 "PMD.AvoidDuplicateLiterals" }) 117 public void onGuestAgentCommand(GuestAgentCommand event) 118 throws IOException { 119 if (qemuChannel() == null) { 120 return; 121 } 122 var command = event.command(); 123 logger.fine(() -> "guest agent(out): " + command.toString()); 124 String asText; 125 try { 126 asText = command.asText(); 127 } catch (JsonProcessingException e) { 128 logger.log(Level.SEVERE, e, 129 () -> "Cannot serialize Json: " + e.getMessage()); 130 return; 131 } 132 synchronized (executing) { 133 if (writer().isPresent()) { 134 executing.add(command); 135 sendCommand(asText); 136 } 137 } 138 } 139 140 /** 141 * Shutdown the VM. 142 * 143 * @param event the event 144 */ 145 @Handler(priority = 200) 146 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 147 public void onStop(Stop event) { 148 if (!connected) { 149 logger.fine(() -> "No guest agent connection," 150 + " cannot send shutdown command"); 151 return; 152 } 153 154 // We have a connection to the guest agent attempt shutdown. 155 powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { 156 var now = Instant.now(); 157 event.setAssociated(Instant.class, now); 158 return now; 159 }); 160 var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); 161 if (waitUntil.isBefore(Instant.now())) { 162 return; 163 } 164 event.suspendHandling(); 165 suspendedStop = event; 166 logger.fine(() -> "Sending powerdown command, waiting for" 167 + " termination until " + waitUntil); 168 powerdownTimer = Components.schedule(t -> { 169 logger.fine(() -> "Powerdown timeout reached."); 170 synchronized (this) { 171 powerdownTimer = null; 172 if (suspendedStop != null) { 173 suspendedStop.resumeHandling(); 174 suspendedStop = null; 175 } 176 } 177 }, waitUntil); 178 rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); 179 } 180 181 /** 182 * On process exited. 183 * 184 * @param event the event 185 */ 186 @Handler 187 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 188 public void onProcessExited(ProcessExited event) { 189 if (!event.startedBy().associated(CommandDefinition.class) 190 .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { 191 return; 192 } 193 synchronized (this) { 194 if (powerdownTimer != null) { 195 powerdownTimer.cancel(); 196 } 197 if (suspendedStop != null) { 198 suspendedStop.resumeHandling(); 199 suspendedStop = null; 200 } 201 } 202 } 203 204 /** 205 * On configure qemu. 206 * 207 * @param event the event 208 */ 209 @Handler 210 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 211 public void onConfigureQemu(ConfigureQemu event) { 212 int newTimeout = event.configuration().vm.powerdownTimeout; 213 if (powerdownTimeout != newTimeout) { 214 powerdownTimeout = newTimeout; 215 synchronized (this) { 216 if (powerdownTimer != null) { 217 powerdownTimer 218 .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); 219 } 220 221 } 222 } 223 } 224}