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.math.BigInteger;
035import java.time.Instant;
036import java.util.Optional;
037import java.util.logging.Level;
038import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
039import org.jdrupes.vmoperator.common.Constants.Crd;
040import org.jdrupes.vmoperator.common.Constants.Status;
041import org.jdrupes.vmoperator.common.Constants.Status.Condition;
042import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
043import org.jdrupes.vmoperator.common.K8s;
044import org.jdrupes.vmoperator.common.VmDefinition;
045import org.jdrupes.vmoperator.common.VmDefinitionStub;
046import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent;
047import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
048import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged;
049import org.jdrupes.vmoperator.runner.qemu.events.Exit;
050import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
051import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
052import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
053import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
054import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
055import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected;
056import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn;
057import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut;
058import org.jdrupes.vmoperator.util.GsonPtr;
059import org.jgrapes.core.Channel;
060import org.jgrapes.core.Components;
061import org.jgrapes.core.Components.Timer;
062import org.jgrapes.core.annotation.Handler;
063import org.jgrapes.core.events.HandlingError;
064import org.jgrapes.core.events.Start;
065
066/**
067 * Updates the CR status.
068 */
069@SuppressWarnings({ "PMD.CouplingBetweenObjects" })
070public class StatusUpdater extends VmDefUpdater {
071
072    @SuppressWarnings("PMD.FieldNamingConventions")
073    private static final Gson gson = new JSON().getGson();
074    @SuppressWarnings("PMD.FieldNamingConventions")
075    private static final ObjectMapper objectMapper
076        = new ObjectMapper().registerModule(new JavaTimeModule());
077
078    private boolean guestShutdownStops;
079    private boolean shutdownByGuest;
080    private VmDefinitionStub vmStub;
081    private String loggedInUser;
082    private BigInteger lastRamValue;
083    private Instant lastRamChange;
084    private Timer balloonTimer;
085    private BigInteger targetRamValue;
086
087    /**
088     * Instantiates a new status updater.
089     *
090     * @param componentChannel the component channel
091     */
092    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
093    public StatusUpdater(Channel componentChannel) {
094        super(componentChannel);
095        attach(new ConsoleTracker(componentChannel));
096    }
097
098    /**
099     * On handling error.
100     *
101     * @param event the event
102     */
103    @Handler(channels = Channel.class)
104    public void onHandlingError(HandlingError event) {
105        if (event.throwable() instanceof ApiException exc) {
106            logger.log(Level.WARNING, exc,
107                () -> "Problem accessing kubernetes: " + exc.getResponseBody());
108            event.stop();
109        }
110    }
111
112    /**
113     * Handle the start event.
114     *
115     * @param event the event
116     * @throws IOException 
117     * @throws ApiException 
118     */
119    @Handler
120    public void onStart(Start event) {
121        if (namespace == null) {
122            return;
123        }
124        try {
125            vmStub = VmDefinitionStub.get(apiClient,
126                new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
127                namespace, vmName);
128            var vmDef = vmStub.model().orElse(null);
129            if (vmDef == null) {
130                return;
131            }
132            vmStub.updateStatus(from -> {
133                JsonObject status = from.statusJson();
134                status.addProperty(Status.RUNNER_VERSION, Optional.ofNullable(
135                    Runner.class.getPackage().getImplementationVersion())
136                    .orElse("(unknown)"));
137                status.remove(Status.LOGGED_IN_USER);
138                return status;
139            });
140        } catch (ApiException e) {
141            logger.log(Level.SEVERE, e,
142                () -> "Cannot access VM object, terminating.");
143            event.cancel(true);
144            fire(new Exit(1));
145        }
146    }
147
148    /**
149     * On runner configuration update.
150     *
151     * @param event the event
152     * @throws ApiException 
153     */
154    @Handler
155    public void onConfigureQemu(ConfigureQemu event)
156            throws ApiException {
157        guestShutdownStops = event.configuration().guestShutdownStops;
158        loggedInUser = event.configuration().vm.display.loggedInUser;
159        targetRamValue = event.configuration().vm.currentRam;
160
161        // Remainder applies only if we have a connection to k8s.
162        if (vmStub == null) {
163            return;
164        }
165        vmStub.updateStatus(from -> {
166            JsonObject status = from.statusJson();
167            if (!event.configuration().hasDisplayPassword) {
168                status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
169            }
170            status.getAsJsonArray("conditions").asList().stream()
171                .map(cond -> (JsonObject) cond)
172                .filter(cond -> Condition.RUNNING
173                    .equals(cond.get("type").getAsString()))
174                .forEach(cond -> cond.addProperty("observedGeneration",
175                    from.getMetadata().getGeneration()));
176            updateUserLoggedIn(from);
177            return status;
178        });
179    }
180
181    /**
182     * On runner state changed.
183     *
184     * @param event the event
185     * @throws ApiException 
186     */
187    @Handler
188    @SuppressWarnings({ "PMD.AssignmentInOperand" })
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, Condition.RUNNING, running, event.reason(),
198                event.message());
199            JsonObject status = updateCondition(vmDef, Condition.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, Condition.CONSOLE_CONNECTED, false,
216                    "VmStopped",
217                    "The VM is not running");
218
219                // In case we had an irregular shutdown
220                updateCondition(from, Condition.USER_LOGGED_IN, false,
221                    "VmStopped", "The VM is not running");
222                status.remove(Status.OSINFO);
223                updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
224                    "The VM is not running");
225            }
226            return status;
227        }, vmDef);
228
229        // Maybe stop VM
230        if (event.runState() == RunState.TERMINATING && !event.failed()
231            && guestShutdownStops && shutdownByGuest) {
232            logger.info(() -> "Stopping VM because of shutdown by guest.");
233            var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
234                new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state"
235                    + "\", \"value\": \"Stopped\"}]"),
236                apiClient.defaultPatchOptions());
237            if (!res.isPresent()) {
238                logger.warning(
239                    () -> "Cannot patch pod annotations for: " + vmStub.name());
240            }
241        }
242
243        // Log event
244        var evt = new EventsV1Event()
245            .reportingController(Crd.GROUP + "/" + APP_NAME)
246            .action("StatusUpdate").reason(event.reason())
247            .note(event.message());
248        K8s.createEvent(apiClient, vmDef, evt);
249    }
250
251    private void updateUserLoggedIn(VmDefinition from) {
252        if (loggedInUser == null) {
253            updateCondition(from, Condition.USER_LOGGED_IN, false,
254                Reason.NOT_REQUESTED, "No user to be logged in");
255            return;
256        }
257        if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) {
258            updateCondition(from, Condition.USER_LOGGED_IN, false,
259                "VmopAgentDisconnected", "Waiting for VMOP agent to connect");
260            return;
261        }
262        if (!from.fromStatus(Status.LOGGED_IN_USER).map(loggedInUser::equals)
263            .orElse(false)) {
264            updateCondition(from, Condition.USER_LOGGED_IN, false,
265                "Processing", "Waiting for user to be logged in");
266        }
267        updateCondition(from, Condition.USER_LOGGED_IN, true,
268            Reason.LOGGED_IN, "User is logged in");
269    }
270
271    /**
272     * Update the current RAM size in the status. Balloon changes happen
273     * more than once every second during changes. While this is nice
274     * to watch, this puts a heavy load on the system. Therefore we
275     * only update the status once every 15 seconds or when the target
276     * value is reached.
277     *
278     * @param event the event
279     * @throws ApiException 
280     */
281    @Handler
282    public void onBallonChange(BalloonChangeEvent event) throws ApiException {
283        if (vmStub == null) {
284            return;
285        }
286        Instant now = Instant.now();
287        if (lastRamChange == null
288            || lastRamChange.isBefore(now.minusSeconds(15))
289            || event.size().equals(targetRamValue)) {
290            if (balloonTimer != null) {
291                balloonTimer.cancel();
292                balloonTimer = null;
293            }
294            lastRamChange = now;
295            lastRamValue = event.size();
296            updateRam();
297            return;
298        }
299
300        // Save for later processing and maybe start timer
301        lastRamChange = now;
302        lastRamValue = event.size();
303        if (balloonTimer != null) {
304            return;
305        }
306        final var pipeline = activeEventPipeline();
307        balloonTimer = Components.schedule(t -> {
308            pipeline.submit("Update RAM size", () -> {
309                try {
310                    updateRam();
311                } catch (ApiException e) {
312                    logger.log(Level.WARNING, e,
313                        () -> "Failed to update ram size: " + e.getMessage());
314                }
315                balloonTimer = null;
316            });
317        }, now.plusSeconds(15));
318    }
319
320    private void updateRam() throws ApiException {
321        vmStub.updateStatus(from -> {
322            JsonObject status = from.statusJson();
323            status.addProperty(Status.RAM,
324                new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI)
325                    .toSuffixedString());
326            return status;
327        });
328    }
329
330    /**
331     * On ballon change.
332     *
333     * @param event the event
334     * @throws ApiException 
335     */
336    @Handler
337    public void onCpuChange(HotpluggableCpuStatus event) throws ApiException {
338        if (vmStub == null) {
339            return;
340        }
341        vmStub.updateStatus(from -> {
342            JsonObject status = from.statusJson();
343            status.addProperty(Status.CPUS, event.usedCpus().size());
344            return status;
345        });
346    }
347
348    /**
349     * On ballon change.
350     *
351     * @param event the event
352     * @throws ApiException 
353     */
354    @Handler
355    public void onDisplayPasswordChanged(DisplayPasswordChanged event)
356            throws ApiException {
357        if (vmStub == null) {
358            return;
359        }
360        vmStub.updateStatus(from -> {
361            JsonObject status = from.statusJson();
362            status.addProperty(Status.DISPLAY_PASSWORD_SERIAL,
363                status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1);
364            return status;
365        });
366    }
367
368    /**
369     * On shutdown.
370     *
371     * @param event the event
372     * @throws ApiException the api exception
373     */
374    @Handler
375    public void onShutdown(ShutdownEvent event) throws ApiException {
376        shutdownByGuest = event.byGuest();
377    }
378
379    /**
380     * On osinfo.
381     *
382     * @param event the event
383     * @throws ApiException 
384     */
385    @Handler
386    public void onOsinfo(OsinfoEvent event) throws ApiException {
387        if (vmStub == null) {
388            return;
389        }
390        var asGson = gson.toJsonTree(
391            objectMapper.convertValue(event.osinfo(), Object.class));
392        vmStub.updateStatus(from -> {
393            JsonObject status = from.statusJson();
394            status.add(Status.OSINFO, asGson);
395            return status;
396        });
397
398    }
399
400    /**
401     * @param event the event
402     * @throws ApiException 
403     */
404    @Handler
405    @SuppressWarnings("PMD.AssignmentInOperand")
406    public void onVmopAgentConnected(VmopAgentConnected event)
407            throws ApiException {
408        VmDefinition vmDef;
409        if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
410            return;
411        }
412        vmStub.updateStatus(from -> {
413            var status = updateCondition(vmDef, "VmopAgentConnected",
414                true, "VmopAgentStarted", "The VM operator agent is running");
415            updateUserLoggedIn(from);
416            return status;
417        }, vmDef);
418    }
419
420    /**
421     * @param event the event
422     * @throws ApiException 
423     */
424    @Handler
425    public void onVmopAgentLoggedIn(VmopAgentLoggedIn event)
426            throws ApiException {
427        vmStub.updateStatus(from -> {
428            JsonObject status = from.statusJson();
429            status.addProperty(Status.LOGGED_IN_USER,
430                event.triggering().user());
431            updateUserLoggedIn(from);
432            return status;
433        });
434    }
435
436    /**
437     * @param event the event
438     * @throws ApiException 
439     */
440    @Handler
441    public void onVmopAgentLoggedOut(VmopAgentLoggedOut event)
442            throws ApiException {
443        vmStub.updateStatus(from -> {
444            JsonObject status = from.statusJson();
445            status.remove(Status.LOGGED_IN_USER);
446            updateUserLoggedIn(from);
447            return status;
448        });
449    }
450}