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.ChannelDictionary.Value;
046import org.jdrupes.vmoperator.manager.events.ChannelTracker;
047import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
048import org.jdrupes.vmoperator.manager.events.ModifyVm;
049import org.jdrupes.vmoperator.manager.events.ResetVm;
050import org.jdrupes.vmoperator.manager.events.VmChannel;
051import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
052import org.jdrupes.vmoperator.util.DataPath;
053import org.jgrapes.core.Channel;
054import org.jgrapes.core.Event;
055import org.jgrapes.core.Manager;
056import org.jgrapes.core.annotation.Handler;
057import org.jgrapes.util.events.ConfigurationUpdate;
058import org.jgrapes.webconsole.base.Conlet.RenderMode;
059import org.jgrapes.webconsole.base.ConletBaseModel;
060import org.jgrapes.webconsole.base.ConsoleConnection;
061import org.jgrapes.webconsole.base.ConsoleRole;
062import org.jgrapes.webconsole.base.ConsoleUser;
063import org.jgrapes.webconsole.base.WebConsoleUtils;
064import org.jgrapes.webconsole.base.events.AddConletType;
065import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
066import org.jgrapes.webconsole.base.events.ConsoleReady;
067import org.jgrapes.webconsole.base.events.DisplayNotification;
068import org.jgrapes.webconsole.base.events.NotifyConletModel;
069import org.jgrapes.webconsole.base.events.NotifyConletView;
070import org.jgrapes.webconsole.base.events.OpenModalDialog;
071import org.jgrapes.webconsole.base.events.RenderConlet;
072import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
073import org.jgrapes.webconsole.base.events.SetLocale;
074import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
075
076/**
077 * The Class {@link VmMgmt}.
078 */
079@SuppressWarnings({ "PMD.CouplingBetweenObjects", "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" })
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    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
181            ConsoleConnection channel, String conletId, VmsModel conletState)
182            throws Exception {
183        Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class);
184        boolean sendVmInfos = false;
185        if (event.renderAs().contains(RenderMode.Preview)) {
186            Template tpl
187                = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html");
188            channel.respond(new RenderConlet(type(), conletId,
189                processTemplate(event, tpl,
190                    fmModel(event, channel, conletId, conletState)))
191                        .setRenderAs(
192                            RenderMode.Preview.addModifiers(event.renderAs()))
193                        .setSupportedModes(MODES));
194            renderedAs.add(RenderMode.Preview);
195            channel.respond(new NotifyConletView(type(),
196                conletId, "summarySeries", summarySeries.entries()));
197            var summary = evaluateSummary(false);
198            channel.respond(new NotifyConletView(type(),
199                conletId, "updateSummary", summary));
200            sendVmInfos = true;
201        }
202        if (event.renderAs().contains(RenderMode.View)) {
203            Template tpl
204                = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html");
205            channel.respond(new RenderConlet(type(), conletId,
206                processTemplate(event, tpl,
207                    fmModel(event, channel, conletId, conletState)))
208                        .setRenderAs(
209                            RenderMode.View.addModifiers(event.renderAs()))
210                        .setSupportedModes(MODES));
211            renderedAs.add(RenderMode.View);
212            sendVmInfos = true;
213        }
214        if (sendVmInfos) {
215            for (var item : channelTracker.values()) {
216                updateVm(channel, conletId, item.associated());
217            }
218        }
219        return renderedAs;
220    }
221
222    private void updateVm(ConsoleConnection channel, String conletId,
223            VmDefinition vmDef) {
224        var user = WebConsoleUtils.userFromSession(channel.session())
225            .map(ConsoleUser::getName).orElse(null);
226        var roles = WebConsoleUtils.rolesFromSession(channel.session())
227            .stream().map(ConsoleRole::getName).toList();
228        channel.respond(new NotifyConletView(type(), conletId, "updateVm",
229            simplifiedVmDefinition(vmDef, user, roles)));
230    }
231
232    private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef,
233            String user, List<String> roles) {
234        // Convert RAM sizes to unitless numbers
235        var spec = DataPath.deepCopy(vmDef.spec());
236        spec.remove("cloudInit");
237        var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get();
238        vmSpec.remove("networks");
239        vmSpec.remove("disks");
240        vmSpec.put("maximumRam", Quantity.fromString(
241            DataPath.<String> get(vmSpec, "maximumRam").orElse("0")).getNumber()
242            .toBigInteger());
243        vmSpec.put("currentRam", Quantity.fromString(
244            DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
245            .toBigInteger());
246        var status = DataPath.deepCopy(vmDef.status());
247        status.put(Status.RAM, Quantity.fromString(
248            DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber()
249            .toBigInteger());
250
251        // Build result
252        var perms = vmDef.permissionsFor(user, roles);
253        return Map.of("metadata",
254            Map.of("namespace", vmDef.namespace(),
255                "name", vmDef.name()),
256            "spec", spec,
257            "status", status,
258            "nodeName", vmDef.extra().nodeName(),
259            "consoleAccessible", vmDef.consoleAccessible(user, perms),
260            "permissions", perms);
261    }
262
263    /**
264     * Track the VM definitions.
265     *
266     * @param event the event
267     * @param channel the channel
268     * @throws IOException 
269     */
270    @Handler(namedChannels = "manager")
271    @SuppressWarnings({ "PMD.CognitiveComplexity",
272        "PMD.AvoidInstantiatingObjectsInLoops" })
273    public void onVmResourceChanged(VmResourceChanged event, VmChannel channel)
274            throws IOException {
275        var vmName = event.vmDefinition().name();
276        if (event.type() == K8sObserver.ResponseType.DELETED) {
277            channelTracker.remove(vmName);
278            for (var entry : conletIdsByConsoleConnection().entrySet()) {
279                for (String conletId : entry.getValue()) {
280                    entry.getKey().respond(new NotifyConletView(type(),
281                        conletId, "removeVm", vmName));
282                }
283            }
284        } else {
285            var vmDef = event.vmDefinition();
286            channelTracker.put(vmName, channel, vmDef);
287            for (var entry : conletIdsByConsoleConnection().entrySet()) {
288                for (String conletId : entry.getValue()) {
289                    updateVm(entry.getKey(), conletId, vmDef);
290                }
291            }
292        }
293
294        var summary = evaluateSummary(true);
295        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
296        for (var entry : conletIdsByConsoleConnection().entrySet()) {
297            for (String conletId : entry.getValue()) {
298                entry.getKey().respond(new NotifyConletView(type(),
299                    conletId, "updateSummary", summary));
300            }
301        }
302    }
303
304    /**
305     * Handle the periodic update event by sending {@link NotifyConletView}
306     * events.
307     *
308     * @param event the event
309     * @param connection the console connection
310     */
311    @Handler
312    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
313    public void onUpdate(Update event, ConsoleConnection connection) {
314        var summary = evaluateSummary(false);
315        summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam);
316        for (String conletId : conletIds(connection)) {
317            connection.respond(new NotifyConletView(type(),
318                conletId, "updateSummary", summary));
319        }
320    }
321
322    /**
323     * The Class Summary.
324     */
325    @SuppressWarnings("PMD.DataClass")
326    public static class Summary {
327
328        /** The total vms. */
329        public int totalVms;
330
331        /** The running vms. */
332        public long runningVms;
333
334        /** The used cpus. */
335        public long usedCpus;
336
337        /** The used ram. */
338        public BigInteger usedRam = BigInteger.ZERO;
339
340        /**
341         * Gets the total vms.
342         *
343         * @return the totalVms
344         */
345        public int getTotalVms() {
346            return totalVms;
347        }
348
349        /**
350         * Gets the running vms.
351         *
352         * @return the runningVms
353         */
354        public long getRunningVms() {
355            return runningVms;
356        }
357
358        /**
359         * Gets the used cpus.
360         *
361         * @return the usedCpus
362         */
363        public long getUsedCpus() {
364            return usedCpus;
365        }
366
367        /**
368         * Gets the used ram. Returned as String for Json rendering.
369         *
370         * @return the usedRam
371         */
372        public String getUsedRam() {
373            return usedRam.toString();
374        }
375
376    }
377
378    private Summary evaluateSummary(boolean force) {
379        if (!force && cachedSummary != null) {
380            return cachedSummary;
381        }
382        Summary summary = new Summary();
383        for (var vmDef : channelTracker.associated()) {
384            summary.totalVms += 1;
385            summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
386                .map(Number::intValue).orElse(0);
387            summary.usedRam = summary.usedRam
388                .add(vmDef.<String> fromStatus(Status.RAM)
389                    .map(r -> Quantity.fromString(r).getNumber().toBigInteger())
390                    .orElse(BigInteger.ZERO));
391            if (vmDef.conditionStatus("Running").orElse(false)) {
392                summary.runningVms += 1;
393            }
394        }
395        cachedSummary = summary;
396        return summary;
397    }
398
399    @Override
400    @SuppressWarnings({ "PMD.NcssCount" })
401    protected void doUpdateConletState(NotifyConletModel event,
402            ConsoleConnection channel, VmsModel model) throws Exception {
403        event.stop();
404        String vmName = event.param(0);
405        var value = channelTracker.value(vmName);
406        var vmChannel = value.map(Value::channel).orElse(null);
407        var vmDef = value.map(Value::associated).orElse(null);
408        if (vmDef == null) {
409            return;
410        }
411        var user = WebConsoleUtils.userFromSession(channel.session())
412            .map(ConsoleUser::getName).orElse("");
413        var roles = WebConsoleUtils.rolesFromSession(channel.session())
414            .stream().map(ConsoleRole::getName).toList();
415        var perms = vmDef.permissionsFor(user, roles);
416        switch (event.method()) {
417        case "start":
418            if (perms.contains(VmDefinition.Permission.START)) {
419                vmChannel.fire(new ModifyVm(vmName, "state", "Running"));
420            }
421            break;
422        case "stop":
423            if (perms.contains(VmDefinition.Permission.STOP)) {
424                vmChannel.fire(new ModifyVm(vmName, "state", "Stopped"));
425            }
426            break;
427        case "reset":
428            if (perms.contains(VmDefinition.Permission.RESET)) {
429                confirmReset(event, channel, model, vmName);
430            }
431            break;
432        case "resetConfirmed":
433            if (perms.contains(VmDefinition.Permission.RESET)) {
434                vmChannel.fire(new ResetVm(vmName));
435            }
436            break;
437        case "openConsole":
438            openConsole(channel, model, vmChannel, vmDef, user, perms);
439            break;
440        case "cpus":
441            vmChannel.fire(new ModifyVm(vmName, "currentCpus",
442                new BigDecimal(event.param(1).toString()).toBigInteger()));
443            break;
444        case "ram":
445            vmChannel.fire(new ModifyVm(vmName, "currentRam",
446                new Quantity(new BigDecimal(event.param(1).toString()),
447                    Format.BINARY_SI).toSuffixedString()));
448            break;
449        default:// ignore
450            break;
451        }
452    }
453
454    private void confirmReset(NotifyConletModel event,
455            ConsoleConnection channel, VmsModel model, String vmName)
456            throws TemplateNotFoundException,
457            MalformedTemplateNameException, ParseException, IOException {
458        Template tpl = freemarkerConfig()
459            .getTemplate("VmMgmt-confirmReset.ftl.html");
460        ResourceBundle resourceBundle = resourceBundle(channel.locale());
461        var fmModel = fmModel(event, channel, model.getConletId(), model);
462        fmModel.put("vmName", vmName);
463        channel.respond(new OpenModalDialog(type(), model.getConletId(),
464            processTemplate(event, tpl, fmModel))
465                .addOption("cancelable", true).addOption("closeLabel", "")
466                .addOption("title",
467                    resourceBundle.getString("confirmResetTitle")));
468    }
469
470    private void openConsole(ConsoleConnection channel, VmsModel model,
471            VmChannel vmChannel, VmDefinition vmDef, String user,
472            Set<Permission> perms) {
473        ResourceBundle resourceBundle = resourceBundle(channel.locale());
474        if (!vmDef.consoleAccessible(user, perms)) {
475            channel.respond(new DisplayNotification(
476                resourceBundle.getString("consoleTakenNotification"),
477                Map.of("autoClose", 5_000, "type", "Warning")));
478            return;
479        }
480        var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
481            e -> gotPassword(channel, model, vmDef, e));
482        vmChannel.fire(pwQuery);
483    }
484
485    private void gotPassword(ConsoleConnection channel, VmsModel model,
486            VmDefinition vmDef, GetDisplaySecret event) {
487        if (!event.secretAvailable()) {
488            return;
489        }
490        vmDef.extra().connectionFile(event.secret(),
491            preferredIpVersion, deleteConnectionFile).ifPresent(
492                cf -> channel.respond(new NotifyConletView(type(),
493                    model.getConletId(), "openConsole", cf)));
494    }
495
496    @Override
497    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
498            String conletId) throws Exception {
499        return true;
500    }
501
502    /**
503     * The Class VmsModel.
504     */
505    public class VmsModel extends ConletBaseModel {
506
507        /**
508         * Instantiates a new vms model.
509         *
510         * @param conletId the conlet id
511         */
512        public VmsModel(String conletId) {
513            super(conletId);
514        }
515
516    }
517}