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.vmmgmt;
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.net.Inet4Address;
031import java.net.Inet6Address;
032import java.time.Duration;
033import java.time.Instant;
034import java.util.Collections;
035import java.util.EnumSet;
036import java.util.List;
037import java.util.Map;
038import java.util.Optional;
039import java.util.ResourceBundle;
040import java.util.Set;
041import org.jdrupes.vmoperator.common.Constants.Status;
042import org.jdrupes.vmoperator.common.K8sObserver;
043import org.jdrupes.vmoperator.common.VmDefinition;
044import org.jdrupes.vmoperator.common.VmDefinition.Permission;
045import org.jdrupes.vmoperator.manager.events.ChannelTracker;
046import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
047import org.jdrupes.vmoperator.manager.events.ModifyVm;
048import org.jdrupes.vmoperator.manager.events.ResetVm;
049import org.jdrupes.vmoperator.manager.events.VmChannel;
050import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
051import org.jdrupes.vmoperator.util.DataPath;
052import org.jgrapes.core.Channel;
053import org.jgrapes.core.Event;
054import org.jgrapes.core.Manager;
055import org.jgrapes.core.annotation.Handler;
056import org.jgrapes.util.events.ConfigurationUpdate;
057import org.jgrapes.webconsole.base.Conlet.RenderMode;
058import org.jgrapes.webconsole.base.ConletBaseModel;
059import org.jgrapes.webconsole.base.ConsoleConnection;
060import org.jgrapes.webconsole.base.ConsoleRole;
061import org.jgrapes.webconsole.base.ConsoleUser;
062import org.jgrapes.webconsole.base.WebConsoleUtils;
063import org.jgrapes.webconsole.base.events.AddConletType;
064import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
065import org.jgrapes.webconsole.base.events.ConsoleReady;
066import org.jgrapes.webconsole.base.events.DisplayNotification;
067import org.jgrapes.webconsole.base.events.NotifyConletModel;
068import org.jgrapes.webconsole.base.events.NotifyConletView;
069import org.jgrapes.webconsole.base.events.OpenModalDialog;
070import org.jgrapes.webconsole.base.events.RenderConlet;
071import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
072import org.jgrapes.webconsole.base.events.SetLocale;
073import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
074
075/**
076 * The Class {@link VmMgmt}.
077 */
078@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
079    "PMD.ExcessiveImports" })
080public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
081
082    private Class<?> preferredIpVersion = Inet4Address.class;
083    private boolean deleteConnectionFile = true;
084    private static final Set<RenderMode> MODES = RenderMode.asSet(
085        RenderMode.Preview, RenderMode.View);
086    private final ChannelTracker<String, VmChannel,
087            VmDefinition> channelTracker = new ChannelTracker<>();
088    private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1));
089    private Summary cachedSummary;
090
091    /**
092     * The periodically generated update event.
093     */
094    public static class Update extends Event<Void> {
095    }
096
097    /**
098     * Creates a new component with its channel set to the given channel.
099     * 
100     * @param componentChannel the channel that the component's handlers listen
101     * on by default and that {@link Manager#fire(Event, Channel...)}
102     * sends the event to
103     */
104    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
105    public VmMgmt(Channel componentChannel) {
106        super(componentChannel);
107        setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
108    }
109
110    /**
111     * Configure the component. 
112     * 
113     * @param event the event
114     */
115    @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
116    @Handler
117    public void onConfigurationUpdate(ConfigurationUpdate event) {
118        event.structured("/Manager/GuiHttpServer"
119            + "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess")
120            .ifPresent(c -> {
121                try {
122                    var dispRes = (Map<String, Object>) c
123                        .getOrDefault("displayResource",
124                            Collections.emptyMap());
125                    switch ((String) dispRes.getOrDefault("preferredIpVersion",
126                        "")) {
127                    case "ipv6":
128                        preferredIpVersion = Inet6Address.class;
129                        break;
130                    case "ipv4":
131                    default:
132                        preferredIpVersion = Inet4Address.class;
133                        break;
134                    }
135
136                    // Delete connection file
137                    deleteConnectionFile
138                        = Optional.ofNullable(c.get("deleteConnectionFile"))
139                            .filter(v -> v instanceof String)
140                            .map(v -> (String) v)
141                            .map(Boolean::parseBoolean).orElse(true);
142                } catch (ClassCastException e) {
143                    logger.config("Malformed configuration: " + e.getMessage());
144                }
145            });
146    }
147
148    /**
149     * On {@link ConsoleReady}, fire the {@link AddConletType}.
150     *
151     * @param event the event
152     * @param channel the channel
153     * @throws TemplateNotFoundException the template not found exception
154     * @throws MalformedTemplateNameException the malformed template name
155     *             exception
156     * @throws ParseException the parse exception
157     * @throws IOException Signals that an I/O exception has occurred.
158     */
159    @Handler
160    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
161            throws TemplateNotFoundException, MalformedTemplateNameException,
162            ParseException, IOException {
163        // Add conlet resources to page
164        channel.respond(new AddConletType(type())
165            .setDisplayNames(
166                localizations(channel.supportedLocales(), "conletName"))
167            .addRenderMode(RenderMode.Preview)
168            .addScript(new ScriptResource().setScriptType("module")
169                .setScriptUri(event.renderSupport().conletResource(
170                    type(), "VmMgmt-functions.js"))));
171    }
172
173    @Override
174    protected Optional<VmsModel> createStateRepresentation(Event<?> event,
175            ConsoleConnection connection, String conletId) throws Exception {
176        return Optional.of(new VmsModel(conletId));
177    }
178
179    @Override
180    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
181    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
182            ConsoleConnection channel, String conletId, VmsModel conletState)
183            throws Exception {
184        Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class);
185        boolean sendVmInfos = false;
186        if (event.renderAs().contains(RenderMode.Preview)) {
187            Template tpl
188                = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html");
189            channel.respond(new RenderConlet(type(), conletId,
190                processTemplate(event, tpl,
191                    fmModel(event, channel, conletId, conletState)))
192                        .setRenderAs(
193                            RenderMode.Preview.addModifiers(event.renderAs()))
194                        .setSupportedModes(MODES));
195            renderedAs.add(RenderMode.Preview);
196            channel.respond(new NotifyConletView(type(),
197                conletId, "summarySeries", summarySeries.entries()));
198            var summary = evaluateSummary(false);
199            channel.respond(new NotifyConletView(type(),
200                conletId, "updateSummary", summary));
201            sendVmInfos = true;
202        }
203        if (event.renderAs().contains(RenderMode.View)) {
204            Template tpl
205                = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html");
206            channel.respond(new RenderConlet(type(), conletId,
207                processTemplate(event, tpl,
208                    fmModel(event, channel, conletId, conletState)))
209                        .setRenderAs(
210                            RenderMode.View.addModifiers(event.renderAs()))
211                        .setSupportedModes(MODES));
212            renderedAs.add(RenderMode.View);
213            sendVmInfos = true;
214        }
215        if (sendVmInfos) {
216            for (var item : channelTracker.values()) {
217                updateVm(channel, conletId, item.associated());
218            }
219        }
220        return renderedAs;
221    }
222
223    private void updateVm(ConsoleConnection channel, String conletId,
224            VmDefinition vmDef) {
225        var user = WebConsoleUtils.userFromSession(channel.session())
226            .map(ConsoleUser::getName).orElse(null);
227        var roles = WebConsoleUtils.rolesFromSession(channel.session())
228            .stream().map(ConsoleRole::getName).toList();
229        channel.respond(new NotifyConletView(type(), conletId, "updateVm",
230            simplifiedVmDefinition(vmDef, user, roles)));
231    }
232
233    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
234    private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef,
235            String user, List<String> roles) {
236        // Convert RAM sizes to unitless numbers
237        var spec = DataPath.deepCopy(vmDef.spec());
238        spec.remove("cloudInit");
239        var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get();
240        vmSpec.remove("networks");
241        vmSpec.remove("disks");
242        vmSpec.put("maximumRam", Quantity.fromString(
243            DataPath.<String> get(vmSpec, "maximumRam").orElse("0")).getNumber()
244            .toBigInteger());
245        vmSpec.put("currentRam", Quantity.fromString(
246            DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
247            .toBigInteger());
248        var status = DataPath.deepCopy(vmDef.status());
249        status.put(Status.RAM, Quantity.fromString(
250            DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber()
251            .toBigInteger());
252
253        // Build result
254        var perms = vmDef.permissionsFor(user, roles);
255        return Map.of("metadata",
256            Map.of("namespace", vmDef.namespace(),
257                "name", vmDef.name()),
258            "spec", spec,
259            "status", status,
260            "nodeName", vmDef.extra().nodeName(),
261            "consoleAccessible", vmDef.consoleAccessible(user, perms),
262            "permissions", perms);
263    }
264
265    /**
266     * Track the VM definitions.
267     *
268     * @param event the event
269     * @param channel the channel
270     * @throws IOException 
271     */
272    @Handler(namedChannels = "manager")
273    @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
274        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
275        "PMD.ConfusingArgumentToVarargsMethod" })
276    public void onVmResourceChanged(VmResourceChanged event, VmChannel channel)
277            throws IOException {
278        var vmName = event.vmDefinition().name();
279        if (event.type() == K8sObserver.ResponseType.DELETED) {
280            channelTracker.remove(vmName);
281            for (var entry : conletIdsByConsoleConnection().entrySet()) {
282                for (String conletId : entry.getValue()) {
283                    entry.getKey().respond(new NotifyConletView(type(),
284                        conletId, "removeVm", vmName));
285                }
286            }
287        } else {
288            var vmDef = event.vmDefinition();
289            channelTracker.put(vmName, channel, vmDef);
290            for (var entry : conletIdsByConsoleConnection().entrySet()) {
291                for (String conletId : entry.getValue()) {
292                    updateVm(entry.getKey(), conletId, vmDef);
293                }
294            }
295        }
296
297        var summary = evaluateSummary(true);
298        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
299        for (var entry : conletIdsByConsoleConnection().entrySet()) {
300            for (String conletId : entry.getValue()) {
301                entry.getKey().respond(new NotifyConletView(type(),
302                    conletId, "updateSummary", summary));
303            }
304        }
305    }
306
307    /**
308     * Handle the periodic update event by sending {@link NotifyConletView}
309     * events.
310     *
311     * @param event the event
312     * @param connection the console connection
313     */
314    @Handler
315    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
316    public void onUpdate(Update event, ConsoleConnection connection) {
317        var summary = evaluateSummary(false);
318        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
319        for (String conletId : conletIds(connection)) {
320            connection.respond(new NotifyConletView(type(),
321                conletId, "updateSummary", summary));
322        }
323    }
324
325    /**
326     * The Class Summary.
327     */
328    @SuppressWarnings("PMD.DataClass")
329    public static class Summary {
330
331        /** The total vms. */
332        public int totalVms;
333
334        /** The running vms. */
335        public long runningVms;
336
337        /** The used cpus. */
338        public long usedCpus;
339
340        /** The used ram. */
341        public BigInteger usedRam = BigInteger.ZERO;
342
343        /**
344         * Gets the total vms.
345         *
346         * @return the totalVms
347         */
348        public int getTotalVms() {
349            return totalVms;
350        }
351
352        /**
353         * Gets the running vms.
354         *
355         * @return the runningVms
356         */
357        public long getRunningVms() {
358            return runningVms;
359        }
360
361        /**
362         * Gets the used cpus.
363         *
364         * @return the usedCpus
365         */
366        public long getUsedCpus() {
367            return usedCpus;
368        }
369
370        /**
371         * Gets the used ram. Returned as String for Json rendering.
372         *
373         * @return the usedRam
374         */
375        public String getUsedRam() {
376            return usedRam.toString();
377        }
378
379    }
380
381    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
382        "PMD.LambdaCanBeMethodReference" })
383    private Summary evaluateSummary(boolean force) {
384        if (!force && cachedSummary != null) {
385            return cachedSummary;
386        }
387        Summary summary = new Summary();
388        for (var vmDef : channelTracker.associated()) {
389            summary.totalVms += 1;
390            summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
391                .map(Number::intValue).orElse(0);
392            summary.usedRam = summary.usedRam
393                .add(vmDef.<String> fromStatus(Status.RAM)
394                    .map(r -> Quantity.fromString(r).getNumber().toBigInteger())
395                    .orElse(BigInteger.ZERO));
396            if (vmDef.conditionStatus("Running").orElse(false)) {
397                summary.runningVms += 1;
398            }
399        }
400        cachedSummary = summary;
401        return summary;
402    }
403
404    @Override
405    @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor",
406        "PMD.NcssCount" })
407    protected void doUpdateConletState(NotifyConletModel event,
408            ConsoleConnection channel, VmsModel model) throws Exception {
409        event.stop();
410        String vmName = event.param(0);
411        var value = channelTracker.value(vmName);
412        var vmChannel = value.map(v -> v.channel()).orElse(null);
413        var vmDef = value.map(v -> v.associated()).orElse(null);
414        if (vmDef == null) {
415            return;
416        }
417        var user = WebConsoleUtils.userFromSession(channel.session())
418            .map(ConsoleUser::getName).orElse("");
419        var roles = WebConsoleUtils.rolesFromSession(channel.session())
420            .stream().map(ConsoleRole::getName).toList();
421        var perms = vmDef.permissionsFor(user, roles);
422        switch (event.method()) {
423        case "start":
424            if (perms.contains(VmDefinition.Permission.START)) {
425                vmChannel.fire(new ModifyVm(vmName, "state", "Running"));
426            }
427            break;
428        case "stop":
429            if (perms.contains(VmDefinition.Permission.STOP)) {
430                vmChannel.fire(new ModifyVm(vmName, "state", "Stopped"));
431            }
432            break;
433        case "reset":
434            if (perms.contains(VmDefinition.Permission.RESET)) {
435                confirmReset(event, channel, model, vmName);
436            }
437            break;
438        case "resetConfirmed":
439            if (perms.contains(VmDefinition.Permission.RESET)) {
440                vmChannel.fire(new ResetVm(vmName));
441            }
442            break;
443        case "openConsole":
444            openConsole(channel, model, vmChannel, vmDef, user, perms);
445            break;
446        case "cpus":
447            vmChannel.fire(new ModifyVm(vmName, "currentCpus",
448                new BigDecimal(event.param(1).toString()).toBigInteger()));
449            break;
450        case "ram":
451            vmChannel.fire(new ModifyVm(vmName, "currentRam",
452                new Quantity(new BigDecimal(event.param(1).toString()),
453                    Format.BINARY_SI).toSuffixedString()));
454            break;
455        default:// ignore
456            break;
457        }
458    }
459
460    private void confirmReset(NotifyConletModel event,
461            ConsoleConnection channel, VmsModel model, String vmName)
462            throws TemplateNotFoundException,
463            MalformedTemplateNameException, ParseException, IOException {
464        Template tpl = freemarkerConfig()
465            .getTemplate("VmMgmt-confirmReset.ftl.html");
466        ResourceBundle resourceBundle = resourceBundle(channel.locale());
467        var fmModel = fmModel(event, channel, model.getConletId(), model);
468        fmModel.put("vmName", vmName);
469        channel.respond(new OpenModalDialog(type(), model.getConletId(),
470            processTemplate(event, tpl, fmModel))
471                .addOption("cancelable", true).addOption("closeLabel", "")
472                .addOption("title",
473                    resourceBundle.getString("confirmResetTitle")));
474    }
475
476    private void openConsole(ConsoleConnection channel, VmsModel model,
477            VmChannel vmChannel, VmDefinition vmDef, String user,
478            Set<Permission> perms) {
479        ResourceBundle resourceBundle = resourceBundle(channel.locale());
480        if (!vmDef.consoleAccessible(user, perms)) {
481            channel.respond(new DisplayNotification(
482                resourceBundle.getString("consoleTakenNotification"),
483                Map.of("autoClose", 5_000, "type", "Warning")));
484            return;
485        }
486        var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
487            e -> gotPassword(channel, model, vmDef, e));
488        vmChannel.fire(pwQuery);
489    }
490
491    private void gotPassword(ConsoleConnection channel, VmsModel model,
492            VmDefinition vmDef, GetDisplaySecret event) {
493        if (!event.secretAvailable()) {
494            return;
495        }
496        vmDef.extra().connectionFile(event.secret(),
497            preferredIpVersion, deleteConnectionFile).ifPresent(
498                cf -> channel.respond(new NotifyConletView(type(),
499                    model.getConletId(), "openConsole", cf)));
500    }
501
502    @Override
503    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
504            String conletId) throws Exception {
505        return true;
506    }
507
508    /**
509     * The Class VmsModel.
510     */
511    public class VmsModel extends ConletBaseModel {
512
513        /**
514         * Instantiates a new vms model.
515         *
516         * @param conletId the conlet id
517         */
518        public VmsModel(String conletId) {
519            super(conletId);
520        }
521
522    }
523}