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