001/*
002 * VM-Operator
003 * Copyright (C) 2023,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.databind.ObjectMapper;
022import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
023import com.google.gson.Gson;
024import com.google.gson.JsonObject;
025import io.kubernetes.client.apimachinery.GroupVersionKind;
026import io.kubernetes.client.custom.Quantity;
027import io.kubernetes.client.custom.Quantity.Format;
028import io.kubernetes.client.custom.V1Patch;
029import io.kubernetes.client.openapi.ApiException;
030import io.kubernetes.client.openapi.JSON;
031import io.kubernetes.client.openapi.models.EventsV1Event;
032import java.io.IOException;
033import java.math.BigDecimal;
034import java.util.logging.Level;
035import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
036import org.jdrupes.vmoperator.common.Constants.Crd;
037import org.jdrupes.vmoperator.common.Constants.Status;
038import org.jdrupes.vmoperator.common.K8s;
039import org.jdrupes.vmoperator.common.VmDefinition;
040import org.jdrupes.vmoperator.common.VmDefinitionStub;
041import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent;
042import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
043import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged;
044import org.jdrupes.vmoperator.runner.qemu.events.Exit;
045import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
046import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
047import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
048import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
049import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
050import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected;
051import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn;
052import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut;
053import org.jdrupes.vmoperator.util.GsonPtr;
054import org.jgrapes.core.Channel;
055import org.jgrapes.core.annotation.Handler;
056import org.jgrapes.core.events.HandlingError;
057import org.jgrapes.core.events.Start;
058
059/**
060 * Updates the CR status.
061 */
062@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
063public class StatusUpdater extends VmDefUpdater {
064
065    @SuppressWarnings("PMD.FieldNamingConventions")
066    private static final Gson gson = new JSON().getGson();
067    @SuppressWarnings("PMD.FieldNamingConventions")
068    private static final ObjectMapper objectMapper
069        = new ObjectMapper().registerModule(new JavaTimeModule());
070
071    private long observedGeneration;
072    private boolean guestShutdownStops;
073    private boolean shutdownByGuest;
074    private VmDefinitionStub vmStub;
075
076    /**
077     * Instantiates a new status updater.
078     *
079     * @param componentChannel the component channel
080     */
081    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
082    public StatusUpdater(Channel componentChannel) {
083        super(componentChannel);
084        attach(new ConsoleTracker(componentChannel));
085    }
086
087    /**
088     * On handling error.
089     *
090     * @param event the event
091     */
092    @Handler(channels = Channel.class)
093    public void onHandlingError(HandlingError event) {
094        if (event.throwable() instanceof ApiException exc) {
095            logger.log(Level.WARNING, exc,
096                () -> "Problem accessing kubernetes: " + exc.getResponseBody());
097            event.stop();
098        }
099    }
100
101    /**
102     * Handle the start event.
103     *
104     * @param event the event
105     * @throws IOException 
106     * @throws ApiException 
107     */
108    @Handler
109    public void onStart(Start event) {
110        if (namespace == null) {
111            return;
112        }
113        try {
114            vmStub = VmDefinitionStub.get(apiClient,
115                new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
116                namespace, vmName);
117            var vmDef = vmStub.model().orElse(null);
118            if (vmDef == null) {
119                return;
120            }
121            observedGeneration = vmDef.getMetadata().getGeneration();
122            vmStub.updateStatus(from -> {
123                JsonObject status = from.statusJson();
124                status.remove(Status.LOGGED_IN_USER);
125                return status;
126            });
127        } catch (ApiException e) {
128            logger.log(Level.SEVERE, e,
129                () -> "Cannot access VM object, terminating.");
130            event.cancel(true);
131            fire(new Exit(1));
132        }
133    }
134
135    /**
136     * On runner configuration update.
137     *
138     * @param event the event
139     * @throws ApiException 
140     */
141    @Handler
142    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
143    public void onConfigureQemu(ConfigureQemu event)
144            throws ApiException {
145        guestShutdownStops = event.configuration().guestShutdownStops;
146
147        // Remainder applies only if we have a connection to k8s.
148        if (vmStub == null) {
149            return;
150        }
151
152        // A change of the runner configuration is typically caused
153        // by a new version of the CR. So we update only if we have
154        // a new version of the CR. There's one exception: the display
155        // password is configured by a file, not by the CR.
156        var vmDef = vmStub.model().orElse(null);
157        if (vmDef == null) {
158            return;
159        }
160        if (vmDef.metadata().getGeneration() == observedGeneration
161            && (event.configuration().hasDisplayPassword
162                || vmDef.statusJson().getAsJsonPrimitive(
163                    Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) {
164            return;
165        }
166        vmStub.updateStatus(from -> {
167            JsonObject status = from.statusJson();
168            if (!event.configuration().hasDisplayPassword) {
169                status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
170            }
171            status.getAsJsonArray("conditions").asList().stream()
172                .map(cond -> (JsonObject) cond).filter(cond -> "Running"
173                    .equals(cond.get("type").getAsString()))
174                .forEach(cond -> cond.addProperty("observedGeneration",
175                    from.getMetadata().getGeneration()));
176            return status;
177        }, vmDef);
178    }
179
180    /**
181     * On runner state changed.
182     *
183     * @param event the event
184     * @throws ApiException 
185     */
186    @Handler
187    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
188        "PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" })
189    public void onRunnerStateChanged(RunnerStateChange event)
190            throws ApiException {
191        VmDefinition vmDef;
192        if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
193            return;
194        }
195        vmStub.updateStatus(from -> {
196            boolean running = event.runState().vmRunning();
197            updateCondition(vmDef, "Running", running, event.reason(),
198                event.message());
199            JsonObject status = updateCondition(vmDef, "Booted",
200                event.runState() == RunState.BOOTED, event.reason(),
201                event.message());
202            if (event.runState() == RunState.STARTING) {
203                status.addProperty(Status.RAM, GsonPtr.to(from.data())
204                    .getAsString("spec", "vm", "maximumRam").orElse("0"));
205                status.addProperty(Status.CPUS, 1);
206            } else if (event.runState() == RunState.STOPPED) {
207                status.addProperty(Status.RAM, "0");
208                status.addProperty(Status.CPUS, 0);
209                status.remove(Status.LOGGED_IN_USER);
210            }
211
212            if (!running) {
213                // In case console connection was still present
214                status.addProperty(Status.CONSOLE_CLIENT, "");
215                updateCondition(from, "ConsoleConnected", false, "VmStopped",
216                    "The VM is not running");
217
218                // In case we had an irregular shutdown
219                status.remove(Status.OSINFO);
220                updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
221                    "The VM is not running");
222            }
223            return status;
224        }, vmDef);
225
226        // Maybe stop VM
227        if (event.runState() == RunState.TERMINATING && !event.failed()
228            && guestShutdownStops && shutdownByGuest) {
229            logger.info(() -> "Stopping VM because of shutdown by guest.");
230            var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
231                new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state"
232                    + "\", \"value\": \"Stopped\"}]"),
233                apiClient.defaultPatchOptions());
234            if (!res.isPresent()) {
235                logger.warning(
236                    () -> "Cannot patch pod annotations for: " + vmStub.name());
237            }
238        }
239
240        // Log event
241        var evt = new EventsV1Event()
242            .reportingController(Crd.GROUP + "/" + APP_NAME)
243            .action("StatusUpdate").reason(event.reason())
244            .note(event.message());
245        K8s.createEvent(apiClient, vmDef, evt);
246    }
247
248    /**
249     * On ballon change.
250     *
251     * @param event the event
252     * @throws ApiException 
253     */
254    @Handler
255    public void onBallonChange(BalloonChangeEvent event) throws ApiException {
256        if (vmStub == null) {
257            return;
258        }
259        vmStub.updateStatus(from -> {
260            JsonObject status = from.statusJson();
261            status.addProperty(Status.RAM,
262                new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
263                    .toSuffixedString());
264            return status;
265        });
266    }
267
268    /**
269     * On ballon change.
270     *
271     * @param event the event
272     * @throws ApiException 
273     */
274    @Handler
275    public void onCpuChange(HotpluggableCpuStatus event) throws ApiException {
276        if (vmStub == null) {
277            return;
278        }
279        vmStub.updateStatus(from -> {
280            JsonObject status = from.statusJson();
281            status.addProperty(Status.CPUS, event.usedCpus().size());
282            return status;
283        });
284    }
285
286    /**
287     * On ballon change.
288     *
289     * @param event the event
290     * @throws ApiException 
291     */
292    @Handler
293    public void onDisplayPasswordChanged(DisplayPasswordChanged event)
294            throws ApiException {
295        if (vmStub == null) {
296            return;
297        }
298        vmStub.updateStatus(from -> {
299            JsonObject status = from.statusJson();
300            status.addProperty(Status.DISPLAY_PASSWORD_SERIAL,
301                status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1);
302            return status;
303        });
304    }
305
306    /**
307     * On shutdown.
308     *
309     * @param event the event
310     * @throws ApiException the api exception
311     */
312    @Handler
313    public void onShutdown(ShutdownEvent event) throws ApiException {
314        shutdownByGuest = event.byGuest();
315    }
316
317    /**
318     * On osinfo.
319     *
320     * @param event the event
321     * @throws ApiException 
322     */
323    @Handler
324    public void onOsinfo(OsinfoEvent event) throws ApiException {
325        if (vmStub == null) {
326            return;
327        }
328        var asGson = gson.toJsonTree(
329            objectMapper.convertValue(event.osinfo(), Object.class));
330        vmStub.updateStatus(from -> {
331            JsonObject status = from.statusJson();
332            status.add(Status.OSINFO, asGson);
333            return status;
334        });
335
336    }
337
338    /**
339     * @param event the event
340     * @throws ApiException 
341     */
342    @Handler
343    @SuppressWarnings("PMD.AssignmentInOperand")
344    public void onVmopAgentConnected(VmopAgentConnected event)
345            throws ApiException {
346        VmDefinition vmDef;
347        if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
348            return;
349        }
350        vmStub.updateStatus(from -> {
351            return updateCondition(vmDef, "VmopAgentConnected",
352                true, "VmopAgentStarted", "The VM operator agent is running");
353        }, vmDef);
354    }
355
356    /**
357     * @param event the event
358     * @throws ApiException 
359     */
360    @Handler
361    @SuppressWarnings("PMD.AssignmentInOperand")
362    public void onVmopAgentLoggedIn(VmopAgentLoggedIn event)
363            throws ApiException {
364        vmStub.updateStatus(from -> {
365            JsonObject status = from.statusJson();
366            status.addProperty(Status.LOGGED_IN_USER,
367                event.triggering().user());
368            return status;
369        });
370    }
371
372    /**
373     * @param event the event
374     * @throws ApiException 
375     */
376    @Handler
377    @SuppressWarnings("PMD.AssignmentInOperand")
378    public void onVmopAgentLoggedOut(VmopAgentLoggedOut event)
379            throws ApiException {
380        vmStub.updateStatus(from -> {
381            JsonObject status = from.statusJson();
382            status.remove(Status.LOGGED_IN_USER);
383            return status;
384        });
385    }
386}