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}