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.finer(() -> "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.finer(() -> 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 logger.fine(() -> "Guest agent triggers: " + osInfo); 101 rep().fire(osInfo); 102 } 103 } 104 } catch (JsonProcessingException e) { 105 throw new IOException(e); 106 } 107 } 108 109 /** 110 * On guest agent command. 111 * 112 * @param event the event 113 * @throws IOException Signals that an I/O exception has occurred. 114 */ 115 @Handler 116 @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", 117 "PMD.AvoidDuplicateLiterals" }) 118 public void onGuestAgentCommand(GuestAgentCommand event) 119 throws IOException { 120 if (qemuChannel() == null) { 121 return; 122 } 123 var command = event.command(); 124 logger.fine(() -> "Guest handles: " + event); 125 String asText; 126 try { 127 asText = command.asText(); 128 logger.finer(() -> "guest agent(out): " + asText); 129 } catch (JsonProcessingException e) { 130 logger.log(Level.SEVERE, e, 131 () -> "Cannot serialize Json: " + e.getMessage()); 132 return; 133 } 134 synchronized (executing) { 135 if (writer().isPresent()) { 136 executing.add(command); 137 sendCommand(asText); 138 } 139 } 140 } 141 142 /** 143 * Shutdown the VM. 144 * 145 * @param event the event 146 */ 147 @Handler(priority = 200) 148 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 149 public void onStop(Stop event) { 150 if (!connected) { 151 logger.fine(() -> "No guest agent connection," 152 + " cannot send shutdown command"); 153 return; 154 } 155 156 // We have a connection to the guest agent attempt shutdown. 157 powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { 158 var now = Instant.now(); 159 event.setAssociated(Instant.class, now); 160 return now; 161 }); 162 var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); 163 if (waitUntil.isBefore(Instant.now())) { 164 return; 165 } 166 event.suspendHandling(); 167 suspendedStop = event; 168 logger.fine(() -> "Attempting shutdown through guest agent," 169 + " waiting for termination until " + waitUntil); 170 powerdownTimer = Components.schedule(t -> { 171 logger.fine(() -> "Powerdown timeout reached."); 172 synchronized (this) { 173 powerdownTimer = null; 174 if (suspendedStop != null) { 175 suspendedStop.resumeHandling(); 176 suspendedStop = null; 177 } 178 } 179 }, waitUntil); 180 rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); 181 } 182 183 /** 184 * On process exited. 185 * 186 * @param event the event 187 */ 188 @Handler 189 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 190 public void onProcessExited(ProcessExited event) { 191 if (!event.startedBy().associated(CommandDefinition.class) 192 .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { 193 return; 194 } 195 synchronized (this) { 196 if (powerdownTimer != null) { 197 powerdownTimer.cancel(); 198 } 199 if (suspendedStop != null) { 200 suspendedStop.resumeHandling(); 201 suspendedStop = null; 202 } 203 } 204 } 205 206 /** 207 * On configure qemu. 208 * 209 * @param event the event 210 */ 211 @Handler 212 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 213 public void onConfigureQemu(ConfigureQemu event) { 214 int newTimeout = event.configuration().vm.powerdownTimeout; 215 if (powerdownTimeout != newTimeout) { 216 powerdownTimeout = newTimeout; 217 synchronized (this) { 218 if (powerdownTimer != null) { 219 powerdownTimer 220 .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); 221 } 222 223 } 224 } 225 } 226}