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.nio.file.Files;
031import java.nio.file.Path;
032import java.time.Instant;
033import java.util.Map;
034import java.util.Optional;
035import java.util.Set;
036import java.util.logging.Level;
037import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
038import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
039import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
040import org.jdrupes.vmoperator.common.K8s;
041import org.jdrupes.vmoperator.common.K8sClient;
042import org.jdrupes.vmoperator.common.K8sDynamicModel;
043import org.jdrupes.vmoperator.common.VmDefinitionModel;
044import org.jdrupes.vmoperator.common.VmDefinitionStub;
045import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent;
046import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
047import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged;
048import org.jdrupes.vmoperator.runner.qemu.events.Exit;
049import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
050import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
051import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
052import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
053import org.jdrupes.vmoperator.util.GsonPtr;
054import org.jgrapes.core.Channel;
055import org.jgrapes.core.Component;
056import org.jgrapes.core.annotation.Handler;
057import org.jgrapes.core.events.HandlingError;
058import org.jgrapes.core.events.Start;
059import org.jgrapes.util.events.ConfigurationUpdate;
060import org.jgrapes.util.events.InitialConfiguration;
061
062/**
063 * Updates the CR status.
064 */
065@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
066public class StatusUpdater extends Component {
067
068    private static final Set<RunState> RUNNING_STATES
069        = Set.of(RunState.RUNNING, RunState.TERMINATING);
070
071    private String namespace;
072    private String vmName;
073    private K8sClient apiClient;
074    private long observedGeneration;
075    private boolean guestShutdownStops;
076    private boolean shutdownByGuest;
077    private VmDefinitionStub vmStub;
078
079    /**
080     * Instantiates a new status updater.
081     *
082     * @param componentChannel the component channel
083     */
084    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
085    public StatusUpdater(Channel componentChannel) {
086        super(componentChannel);
087        try {
088            apiClient = new K8sClient();
089            io.kubernetes.client.openapi.Configuration
090                .setDefaultApiClient(apiClient);
091        } catch (IOException e) {
092            logger.log(Level.SEVERE, e,
093                () -> "Cannot access events API, terminating.");
094            fire(new Exit(1));
095        }
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     * On configuration update.
114     *
115     * @param event the event
116     */
117    @Handler
118    @SuppressWarnings("unchecked")
119    public void onConfigurationUpdate(ConfigurationUpdate event) {
120        event.structured("/Runner").ifPresent(c -> {
121            if (event instanceof InitialConfiguration) {
122                namespace = (String) c.get("namespace");
123                updateNamespace();
124                vmName = Optional.ofNullable((Map<String, String>) c.get("vm"))
125                    .map(vm -> vm.get("name")).orElse(null);
126            }
127        });
128    }
129
130    private void updateNamespace() {
131        if (namespace == null) {
132            var path = Path
133                .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace");
134            if (Files.isReadable(path)) {
135                try {
136                    namespace = Files.lines(path).findFirst().orElse(null);
137                } catch (IOException e) {
138                    logger.log(Level.WARNING, e,
139                        () -> "Cannot read namespace.");
140                }
141            }
142        }
143        if (namespace == null) {
144            logger.warning(() -> "Namespace is unknown, some functions"
145                + " won't be available.");
146        }
147    }
148
149    /**
150     * Handle the start event.
151     *
152     * @param event the event
153     * @throws IOException 
154     * @throws ApiException 
155     */
156    @Handler
157    public void onStart(Start event) {
158        if (namespace == null) {
159            return;
160        }
161        try {
162            vmStub = VmDefinitionStub.get(apiClient,
163                new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
164                namespace, vmName);
165            vmStub.model().ifPresent(model -> {
166                observedGeneration = model.getMetadata().getGeneration();
167            });
168        } catch (ApiException e) {
169            logger.log(Level.SEVERE, e,
170                () -> "Cannot access VM object, terminating.");
171            event.cancel(true);
172            fire(new Exit(1));
173        }
174    }
175
176    /**
177     * On runner configuration update.
178     *
179     * @param event the event
180     * @throws ApiException 
181     */
182    @Handler
183    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
184    public void onConfigureQemu(ConfigureQemu event)
185            throws ApiException {
186        guestShutdownStops = event.configuration().guestShutdownStops;
187
188        // Remainder applies only if we have a connection to k8s.
189        if (vmStub == null) {
190            return;
191        }
192
193        // A change of the runner configuration is typically caused
194        // by a new version of the CR. So we update only if we have
195        // a new version of the CR. There's one exception: the display
196        // password is configured by a file, not by the CR.
197        var vmDef = vmStub.model();
198        if (vmDef.isPresent()
199            && vmDef.get().metadata().getGeneration() == observedGeneration
200            && (event.configuration().hasDisplayPassword
201                || vmDef.get().status().getAsJsonPrimitive(
202                    "displayPasswordSerial").getAsInt() == -1)) {
203            return;
204        }
205        vmStub.updateStatus(vmDef.get(), from -> {
206            JsonObject status = from.status();
207            if (!event.configuration().hasDisplayPassword) {
208                status.addProperty("displayPasswordSerial", -1);
209            }
210            status.getAsJsonArray("conditions").asList().stream()
211                .map(cond -> (JsonObject) cond).filter(cond -> "Running"
212                    .equals(cond.get("type").getAsString()))
213                .forEach(cond -> cond.addProperty("observedGeneration",
214                    from.getMetadata().getGeneration()));
215            return status;
216        });
217    }
218
219    /**
220     * On runner state changed.
221     *
222     * @param event the event
223     * @throws ApiException 
224     */
225    @Handler
226    @SuppressWarnings({ "PMD.AssignmentInOperand",
227        "PMD.AvoidLiteralsInIfCondition" })
228    public void onRunnerStateChanged(RunnerStateChange event)
229            throws ApiException {
230        VmDefinitionModel vmDef;
231        if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
232            return;
233        }
234        vmStub.updateStatus(vmDef, from -> {
235            JsonObject status = from.status();
236            status.getAsJsonArray("conditions").asList().stream()
237                .map(cond -> (JsonObject) cond)
238                .forEach(cond -> {
239                    if ("Running".equals(cond.get("type").getAsString())) {
240                        updateRunningCondition(event, from, cond);
241                    }
242                });
243            if (event.runState() == RunState.STARTING) {
244                status.addProperty("ram", GsonPtr.to(from.data())
245                    .getAsString("spec", "vm", "maximumRam").orElse("0"));
246                status.addProperty("cpus", 1);
247            } else if (event.runState() == RunState.STOPPED) {
248                status.addProperty("ram", "0");
249                status.addProperty("cpus", 0);
250            }
251            return status;
252        });
253
254        // Maybe stop VM
255        if (event.runState() == RunState.TERMINATING && !event.failed()
256            && guestShutdownStops && shutdownByGuest) {
257            logger.info(() -> "Stopping VM because of shutdown by guest.");
258            var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
259                new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state"
260                    + "\", \"value\": \"Stopped\"}]"),
261                apiClient.defaultPatchOptions());
262            if (!res.isPresent()) {
263                logger.warning(
264                    () -> "Cannot patch pod annotations for: " + vmStub.name());
265            }
266        }
267
268        // Log event
269        var evt = new EventsV1Event()
270            .reportingController(VM_OP_GROUP + "/" + APP_NAME)
271            .action("StatusUpdate").reason(event.reason())
272            .note(event.message());
273        K8s.createEvent(apiClient, vmDef, evt);
274    }
275
276    private void updateRunningCondition(RunnerStateChange event,
277            K8sDynamicModel from, JsonObject cond) {
278        boolean reportedRunning
279            = "True".equals(cond.get("status").getAsString());
280        if (RUNNING_STATES.contains(event.runState())
281            && !reportedRunning) {
282            cond.addProperty("status", "True");
283            cond.addProperty("lastTransitionTime",
284                Instant.now().toString());
285        }
286        if (!RUNNING_STATES.contains(event.runState())
287            && reportedRunning) {
288            cond.addProperty("status", "False");
289            cond.addProperty("lastTransitionTime",
290                Instant.now().toString());
291        }
292        cond.addProperty("reason", event.reason());
293        cond.addProperty("message", event.message());
294        cond.addProperty("observedGeneration",
295            from.getMetadata().getGeneration());
296    }
297
298    /**
299     * On ballon change.
300     *
301     * @param event the event
302     * @throws ApiException 
303     */
304    @Handler
305    public void onBallonChange(BalloonChangeEvent event) throws ApiException {
306        if (vmStub == null) {
307            return;
308        }
309        vmStub.updateStatus(from -> {
310            JsonObject status = from.status();
311            status.addProperty("ram",
312                new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
313                    .toSuffixedString());
314            return status;
315        });
316    }
317
318    /**
319     * On ballon change.
320     *
321     * @param event the event
322     * @throws ApiException 
323     */
324    @Handler
325    public void onCpuChange(HotpluggableCpuStatus event) throws ApiException {
326        if (vmStub == null) {
327            return;
328        }
329        vmStub.updateStatus(from -> {
330            JsonObject status = from.status();
331            status.addProperty("cpus", event.usedCpus().size());
332            return status;
333        });
334    }
335
336    /**
337     * On ballon change.
338     *
339     * @param event the event
340     * @throws ApiException 
341     */
342    @Handler
343    public void onDisplayPasswordChanged(DisplayPasswordChanged event)
344            throws ApiException {
345        if (vmStub == null) {
346            return;
347        }
348        vmStub.updateStatus(from -> {
349            JsonObject status = from.status();
350            status.addProperty("displayPasswordSerial",
351                status.get("displayPasswordSerial").getAsLong() + 1);
352            return status;
353        });
354    }
355
356    /**
357     * On shutdown.
358     *
359     * @param event the event
360     * @throws ApiException the api exception
361     */
362    @Handler
363    public void onShutdown(ShutdownEvent event) throws ApiException {
364        shutdownByGuest = event.byGuest();
365    }
366}