001/*
002 * VM-Operator
003 * Copyright (C) 2023,2024 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.google.gson.JsonObject;
022import io.kubernetes.client.apimachinery.GroupVersionKind;
023import io.kubernetes.client.custom.Quantity;
024import io.kubernetes.client.custom.Quantity.Format;
025import io.kubernetes.client.custom.V1Patch;
026import io.kubernetes.client.openapi.ApiException;
027import io.kubernetes.client.openapi.models.EventsV1Event;
028import java.io.IOException;
029import java.math.BigDecimal;
030import java.util.Set;
031import java.util.logging.Level;
032import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
033import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
034import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
035import org.jdrupes.vmoperator.common.K8s;
036import org.jdrupes.vmoperator.common.VmDefinitionModel;
037import org.jdrupes.vmoperator.common.VmDefinitionStub;
038import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent;
039import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
040import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged;
041import org.jdrupes.vmoperator.runner.qemu.events.Exit;
042import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
043import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
044import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
045import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
046import org.jdrupes.vmoperator.util.GsonPtr;
047import org.jgrapes.core.Channel;
048import org.jgrapes.core.annotation.Handler;
049import org.jgrapes.core.events.HandlingError;
050import org.jgrapes.core.events.Start;
051
052/**
053 * Updates the CR status.
054 */
055@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
056public class StatusUpdater extends VmDefUpdater {
057
058    private static final Set<RunState> RUNNING_STATES
059        = Set.of(RunState.RUNNING, RunState.TERMINATING);
060
061    private long observedGeneration;
062    private boolean guestShutdownStops;
063    private boolean shutdownByGuest;
064    private VmDefinitionStub vmStub;
065
066    /**
067     * Instantiates a new status updater.
068     *
069     * @param componentChannel the component channel
070     */
071    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
072    public StatusUpdater(Channel componentChannel) {
073        super(componentChannel);
074        attach(new ConsoleTracker(componentChannel));
075    }
076
077    /**
078     * On handling error.
079     *
080     * @param event the event
081     */
082    @Handler(channels = Channel.class)
083    public void onHandlingError(HandlingError event) {
084        if (event.throwable() instanceof ApiException exc) {
085            logger.log(Level.WARNING, exc,
086                () -> "Problem accessing kubernetes: " + exc.getResponseBody());
087            event.stop();
088        }
089    }
090
091    /**
092     * Handle the start event.
093     *
094     * @param event the event
095     * @throws IOException 
096     * @throws ApiException 
097     */
098    @Handler
099    public void onStart(Start event) {
100        if (namespace == null) {
101            return;
102        }
103        try {
104            vmStub = VmDefinitionStub.get(apiClient,
105                new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
106                namespace, vmName);
107            vmStub.model().ifPresent(model -> {
108                observedGeneration = model.getMetadata().getGeneration();
109            });
110        } catch (ApiException e) {
111            logger.log(Level.SEVERE, e,
112                () -> "Cannot access VM object, terminating.");
113            event.cancel(true);
114            fire(new Exit(1));
115        }
116    }
117
118    /**
119     * On runner configuration update.
120     *
121     * @param event the event
122     * @throws ApiException 
123     */
124    @Handler
125    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
126    public void onConfigureQemu(ConfigureQemu event)
127            throws ApiException {
128        guestShutdownStops = event.configuration().guestShutdownStops;
129
130        // Remainder applies only if we have a connection to k8s.
131        if (vmStub == null) {
132            return;
133        }
134
135        // A change of the runner configuration is typically caused
136        // by a new version of the CR. So we update only if we have
137        // a new version of the CR. There's one exception: the display
138        // password is configured by a file, not by the CR.
139        var vmDef = vmStub.model();
140        if (vmDef.isPresent()
141            && vmDef.get().metadata().getGeneration() == observedGeneration
142            && (event.configuration().hasDisplayPassword
143                || vmDef.get().status().getAsJsonPrimitive(
144                    "displayPasswordSerial").getAsInt() == -1)) {
145            return;
146        }
147        vmStub.updateStatus(vmDef.get(), from -> {
148            JsonObject status = from.status();
149            if (!event.configuration().hasDisplayPassword) {
150                status.addProperty("displayPasswordSerial", -1);
151            }
152            status.getAsJsonArray("conditions").asList().stream()
153                .map(cond -> (JsonObject) cond).filter(cond -> "Running"
154                    .equals(cond.get("type").getAsString()))
155                .forEach(cond -> cond.addProperty("observedGeneration",
156                    from.getMetadata().getGeneration()));
157            return status;
158        });
159    }
160
161    /**
162     * On runner state changed.
163     *
164     * @param event the event
165     * @throws ApiException 
166     */
167    @Handler
168    @SuppressWarnings({ "PMD.AssignmentInOperand",
169        "PMD.AvoidLiteralsInIfCondition" })
170    public void onRunnerStateChanged(RunnerStateChange event)
171            throws ApiException {
172        VmDefinitionModel vmDef;
173        if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
174            return;
175        }
176        vmStub.updateStatus(vmDef, from -> {
177            JsonObject status = from.status();
178            boolean running = RUNNING_STATES.contains(event.runState());
179            updateCondition(vmDef, vmDef.status(), "Running", running,
180                event.reason(), event.message());
181            if (event.runState() == RunState.STARTING) {
182                status.addProperty("ram", GsonPtr.to(from.data())
183                    .getAsString("spec", "vm", "maximumRam").orElse("0"));
184                status.addProperty("cpus", 1);
185            } else if (event.runState() == RunState.STOPPED) {
186                status.addProperty("ram", "0");
187                status.addProperty("cpus", 0);
188            }
189
190            // In case console connection was still present
191            if (!running) {
192                status.addProperty("consoleClient", "");
193                updateCondition(from, status, "ConsoleConnected", false,
194                    "VmStopped", "The VM has been shut down");
195            }
196            return status;
197        });
198
199        // Maybe stop VM
200        if (event.runState() == RunState.TERMINATING && !event.failed()
201            && guestShutdownStops && shutdownByGuest) {
202            logger.info(() -> "Stopping VM because of shutdown by guest.");
203            var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
204                new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state"
205                    + "\", \"value\": \"Stopped\"}]"),
206                apiClient.defaultPatchOptions());
207            if (!res.isPresent()) {
208                logger.warning(
209                    () -> "Cannot patch pod annotations for: " + vmStub.name());
210            }
211        }
212
213        // Log event
214        var evt = new EventsV1Event()
215            .reportingController(VM_OP_GROUP + "/" + APP_NAME)
216            .action("StatusUpdate").reason(event.reason())
217            .note(event.message());
218        K8s.createEvent(apiClient, vmDef, evt);
219    }
220
221    /**
222     * On ballon change.
223     *
224     * @param event the event
225     * @throws ApiException 
226     */
227    @Handler
228    public void onBallonChange(BalloonChangeEvent event) throws ApiException {
229        if (vmStub == null) {
230            return;
231        }
232        vmStub.updateStatus(from -> {
233            JsonObject status = from.status();
234            status.addProperty("ram",
235                new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
236                    .toSuffixedString());
237            return status;
238        });
239    }
240
241    /**
242     * On ballon change.
243     *
244     * @param event the event
245     * @throws ApiException 
246     */
247    @Handler
248    public void onCpuChange(HotpluggableCpuStatus event) throws ApiException {
249        if (vmStub == null) {
250            return;
251        }
252        vmStub.updateStatus(from -> {
253            JsonObject status = from.status();
254            status.addProperty("cpus", event.usedCpus().size());
255            return status;
256        });
257    }
258
259    /**
260     * On ballon change.
261     *
262     * @param event the event
263     * @throws ApiException 
264     */
265    @Handler
266    public void onDisplayPasswordChanged(DisplayPasswordChanged event)
267            throws ApiException {
268        if (vmStub == null) {
269            return;
270        }
271        vmStub.updateStatus(from -> {
272            JsonObject status = from.status();
273            status.addProperty("displayPasswordSerial",
274                status.get("displayPasswordSerial").getAsLong() + 1);
275            return status;
276        });
277    }
278
279    /**
280     * On shutdown.
281     *
282     * @param event the event
283     * @throws ApiException the api exception
284     */
285    @Handler
286    public void onShutdown(ShutdownEvent event) throws ApiException {
287        shutdownByGuest = event.byGuest();
288    }
289}