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