001/*
002 * VM-Operator
003 * Copyright (C) 2023 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.vmconlet;
020
021import com.google.gson.JsonObject;
022import freemarker.core.ParseException;
023import freemarker.template.MalformedTemplateNameException;
024import freemarker.template.Template;
025import freemarker.template.TemplateNotFoundException;
026import io.kubernetes.client.custom.Quantity;
027import io.kubernetes.client.custom.Quantity.Format;
028import java.io.IOException;
029import java.math.BigDecimal;
030import java.math.BigInteger;
031import java.time.Duration;
032import java.time.Instant;
033import java.util.HashSet;
034import java.util.Optional;
035import java.util.Set;
036import org.jdrupes.json.JsonBeanDecoder;
037import org.jdrupes.json.JsonDecodeException;
038import org.jdrupes.vmoperator.common.K8sObserver;
039import org.jdrupes.vmoperator.common.VmDefinitionModel;
040import org.jdrupes.vmoperator.manager.events.ChannelCache;
041import org.jdrupes.vmoperator.manager.events.ModifyVm;
042import org.jdrupes.vmoperator.manager.events.VmChannel;
043import org.jdrupes.vmoperator.manager.events.VmDefChanged;
044import org.jdrupes.vmoperator.util.GsonPtr;
045import org.jgrapes.core.Channel;
046import org.jgrapes.core.Event;
047import org.jgrapes.core.Manager;
048import org.jgrapes.core.annotation.Handler;
049import org.jgrapes.webconsole.base.Conlet.RenderMode;
050import org.jgrapes.webconsole.base.ConletBaseModel;
051import org.jgrapes.webconsole.base.ConsoleConnection;
052import org.jgrapes.webconsole.base.events.AddConletRequest;
053import org.jgrapes.webconsole.base.events.AddConletType;
054import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
055import org.jgrapes.webconsole.base.events.ConsoleReady;
056import org.jgrapes.webconsole.base.events.NotifyConletModel;
057import org.jgrapes.webconsole.base.events.NotifyConletView;
058import org.jgrapes.webconsole.base.events.RenderConlet;
059import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
060import org.jgrapes.webconsole.base.events.SetLocale;
061import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
062
063/**
064 * The Class VmConlet.
065 */
066@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
067public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> {
068
069    private static final Set<RenderMode> MODES = RenderMode.asSet(
070        RenderMode.Preview, RenderMode.View);
071    private final ChannelCache<String, VmChannel,
072            VmDefinitionModel> channelManager = new ChannelCache<>();
073    private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1));
074    private Summary cachedSummary;
075
076    /**
077     * The periodically generated update event.
078     */
079    public static class Update extends Event<Void> {
080    }
081
082    /**
083     * Creates a new component with its channel set to the given channel.
084     * 
085     * @param componentChannel the channel that the component's handlers listen
086     * on by default and that {@link Manager#fire(Event, Channel...)}
087     * sends the event to
088     */
089    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
090    public VmConlet(Channel componentChannel) {
091        super(componentChannel);
092        setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
093    }
094
095    /**
096     * On {@link ConsoleReady}, fire the {@link AddConletType}.
097     *
098     * @param event the event
099     * @param channel the channel
100     * @throws TemplateNotFoundException the template not found exception
101     * @throws MalformedTemplateNameException the malformed template name
102     *             exception
103     * @throws ParseException the parse exception
104     * @throws IOException Signals that an I/O exception has occurred.
105     */
106    @Handler
107    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
108            throws TemplateNotFoundException, MalformedTemplateNameException,
109            ParseException, IOException {
110        // Add conlet resources to page
111        channel.respond(new AddConletType(type())
112            .setDisplayNames(
113                localizations(channel.supportedLocales(), "conletName"))
114            .addRenderMode(RenderMode.Preview)
115            .addScript(new ScriptResource().setScriptType("module")
116                .setScriptUri(event.renderSupport().conletResource(
117                    type(), "VmConlet-functions.js"))));
118    }
119
120    @Override
121    protected Optional<VmsModel> createNewState(AddConletRequest event,
122            ConsoleConnection connection, String conletId) throws Exception {
123        return Optional.of(new VmsModel(conletId));
124    }
125
126    @Override
127    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
128    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
129            ConsoleConnection channel, String conletId, VmsModel conletState)
130            throws Exception {
131        Set<RenderMode> renderedAs = new HashSet<>();
132        boolean sendVmInfos = false;
133        if (event.renderAs().contains(RenderMode.Preview)) {
134            Template tpl
135                = freemarkerConfig().getTemplate("VmConlet-preview.ftl.html");
136            channel.respond(new RenderConlet(type(), conletId,
137                processTemplate(event, tpl,
138                    fmModel(event, channel, conletId, conletState)))
139                        .setRenderAs(
140                            RenderMode.Preview.addModifiers(event.renderAs()))
141                        .setSupportedModes(MODES));
142            renderedAs.add(RenderMode.Preview);
143            channel.respond(new NotifyConletView(type(),
144                conletId, "summarySeries", summarySeries.entries()));
145            var summary = evaluateSummary(false);
146            channel.respond(new NotifyConletView(type(),
147                conletId, "updateSummary", summary));
148            sendVmInfos = true;
149        }
150        if (event.renderAs().contains(RenderMode.View)) {
151            Template tpl
152                = freemarkerConfig().getTemplate("VmConlet-view.ftl.html");
153            channel.respond(new RenderConlet(type(), conletId,
154                processTemplate(event, tpl,
155                    fmModel(event, channel, conletId, conletState)))
156                        .setRenderAs(
157                            RenderMode.View.addModifiers(event.renderAs()))
158                        .setSupportedModes(MODES));
159            renderedAs.add(RenderMode.View);
160            sendVmInfos = true;
161        }
162        if (sendVmInfos) {
163            for (var vmDef : channelManager.associated()) {
164                var def
165                    = JsonBeanDecoder.create(vmDef.data().toString())
166                        .readObject();
167                channel.respond(new NotifyConletView(type(),
168                    conletId, "updateVm", def));
169            }
170        }
171
172        return renderedAs;
173    }
174
175    /**
176     * Track the VM definitions.
177     *
178     * @param event the event
179     * @param channel the channel
180     * @throws JsonDecodeException the json decode exception
181     * @throws IOException 
182     */
183    @Handler(namedChannels = "manager")
184    @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
185        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
186        "PMD.ConfusingArgumentToVarargsMethod" })
187    public void onVmDefChanged(VmDefChanged event, VmChannel channel)
188            throws JsonDecodeException, IOException {
189        var vmName = event.vmDefinition().getMetadata().getName();
190        if (event.type() == K8sObserver.ResponseType.DELETED) {
191            channelManager.remove(vmName);
192            for (var entry : conletIdsByConsoleConnection().entrySet()) {
193                for (String conletId : entry.getValue()) {
194                    entry.getKey().respond(new NotifyConletView(type(),
195                        conletId, "removeVm", vmName));
196                }
197            }
198        } else {
199            var vmDef = new VmDefinitionModel(channel.client().getJSON()
200                .getGson(), cleanup(event.vmDefinition().data()));
201            channelManager.put(vmName, channel, vmDef);
202            var def = JsonBeanDecoder.create(vmDef.data().toString())
203                .readObject();
204            for (var entry : conletIdsByConsoleConnection().entrySet()) {
205                for (String conletId : entry.getValue()) {
206                    entry.getKey().respond(new NotifyConletView(type(),
207                        conletId, "updateVm", def));
208                }
209            }
210        }
211
212        var summary = evaluateSummary(true);
213        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
214        for (var entry : conletIdsByConsoleConnection().entrySet()) {
215            for (String conletId : entry.getValue()) {
216                entry.getKey().respond(new NotifyConletView(type(),
217                    conletId, "updateSummary", summary));
218            }
219        }
220    }
221
222    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
223    private JsonObject cleanup(JsonObject vmDef) {
224        // Clone and remove managed fields
225        var json = vmDef.deepCopy();
226        GsonPtr.to(json).to("metadata").get(JsonObject.class)
227            .remove("managedFields");
228
229        // Convert RAM sizes to unitless numbers
230        var vmSpec = GsonPtr.to(json).to("spec", "vm");
231        vmSpec.set("maximumRam", Quantity.fromString(
232            vmSpec.getAsString("maximumRam").orElse("0")).getNumber()
233            .toBigInteger());
234        vmSpec.set("currentRam", Quantity.fromString(
235            vmSpec.getAsString("currentRam").orElse("0")).getNumber()
236            .toBigInteger());
237        var status = GsonPtr.to(json).to("status");
238        status.set("ram", Quantity.fromString(
239            status.getAsString("ram").orElse("0")).getNumber()
240            .toBigInteger());
241        return json;
242    }
243
244    /**
245     * Handle the periodic update event by sending {@link NotifyConletView}
246     * events.
247     *
248     * @param event the event
249     * @param connection the console connection
250     */
251    @Handler
252    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
253    public void onUpdate(Update event, ConsoleConnection connection) {
254        var summary = evaluateSummary(false);
255        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
256        for (String conletId : conletIds(connection)) {
257            connection.respond(new NotifyConletView(type(),
258                conletId, "updateSummary", summary));
259        }
260    }
261
262    /**
263     * The Class Summary.
264     */
265    @SuppressWarnings("PMD.DataClass")
266    public static class Summary {
267
268        /** The total vms. */
269        public int totalVms;
270
271        /** The running vms. */
272        public int runningVms;
273
274        /** The used cpus. */
275        public int usedCpus;
276
277        /** The used ram. */
278        public BigInteger usedRam = BigInteger.ZERO;
279
280        /**
281         * Gets the total vms.
282         *
283         * @return the totalVms
284         */
285        public int getTotalVms() {
286            return totalVms;
287        }
288
289        /**
290         * Gets the running vms.
291         *
292         * @return the runningVms
293         */
294        public int getRunningVms() {
295            return runningVms;
296        }
297
298        /**
299         * Gets the used cpus.
300         *
301         * @return the usedCpus
302         */
303        public int getUsedCpus() {
304            return usedCpus;
305        }
306
307        /**
308         * Gets the used ram. Returned as String for Json rendering.
309         *
310         * @return the usedRam
311         */
312        public String getUsedRam() {
313            return usedRam.toString();
314        }
315
316    }
317
318    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
319    private Summary evaluateSummary(boolean force) {
320        if (!force && cachedSummary != null) {
321            return cachedSummary;
322        }
323        Summary summary = new Summary();
324        for (var vmDef : channelManager.associated()) {
325            summary.totalVms += 1;
326            var status = GsonPtr.to(vmDef.data()).to("status");
327            summary.usedCpus += status.getAsInt("cpus").orElse(0);
328            summary.usedRam = summary.usedRam.add(status.getAsString("ram")
329                .map(BigInteger::new).orElse(BigInteger.ZERO));
330            for (var c : status.getAsListOf(JsonObject.class, "conditions")) {
331                if ("Running".equals(GsonPtr.to(c).getAsString("type")
332                    .orElse(null))
333                    && "True".equals(GsonPtr.to(c).getAsString("status")
334                        .orElse(null))) {
335                    summary.runningVms += 1;
336                }
337            }
338        }
339        cachedSummary = summary;
340        return summary;
341    }
342
343    @Override
344    @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor")
345    protected void doUpdateConletState(NotifyConletModel event,
346            ConsoleConnection channel, VmsModel conletState)
347            throws Exception {
348        event.stop();
349        var vmName = event.params().asString(0);
350        var vmChannel = channelManager.channel(vmName).orElse(null);
351        if (vmChannel == null) {
352            return;
353        }
354        switch (event.method()) {
355        case "start":
356            fire(new ModifyVm(vmName, "state", "Running", vmChannel));
357            break;
358        case "stop":
359            fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
360            break;
361        case "cpus":
362            fire(new ModifyVm(vmName, "currentCpus",
363                new BigDecimal(event.params().asDouble(1)).toBigInteger(),
364                vmChannel));
365            break;
366        case "ram":
367            fire(new ModifyVm(vmName, "currentRam",
368                new Quantity(new BigDecimal(event.params().asDouble(1)),
369                    Format.BINARY_SI).toSuffixedString(),
370                vmChannel));
371            break;
372        default:// ignore
373            break;
374        }
375    }
376
377    @Override
378    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
379            String conletId) throws Exception {
380        return true;
381    }
382
383    /**
384     * The Class VmsModel.
385     */
386    public class VmsModel extends ConletBaseModel {
387
388        /**
389         * Instantiates a new vms model.
390         *
391         * @param conletId the conlet id
392         */
393        public VmsModel(String conletId) {
394            super(conletId);
395        }
396
397    }
398}