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.common.VmExtraData;
046import org.jdrupes.vmoperator.manager.events.ChannelTracker;
047import org.jdrupes.vmoperator.manager.events.ModifyVm;
048import org.jdrupes.vmoperator.manager.events.PrepareConsole;
049import org.jdrupes.vmoperator.manager.events.ResetVm;
050import org.jdrupes.vmoperator.manager.events.VmChannel;
051import org.jdrupes.vmoperator.manager.events.VmDefChanged;
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.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
080    "PMD.ExcessiveImports" })
081public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
082
083    private Class<?> preferredIpVersion = Inet4Address.class;
084    private boolean deleteConnectionFile = true;
085    private static final Set<RenderMode> MODES = RenderMode.asSet(
086        RenderMode.Preview, RenderMode.View);
087    private final ChannelTracker<String, VmChannel,
088            VmDefinition> channelTracker = new ChannelTracker<>();
089    private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1));
090    private Summary cachedSummary;
091
092    /**
093     * The periodically generated update event.
094     */
095    public static class Update extends Event<Void> {
096    }
097
098    /**
099     * Creates a new component with its channel set to the given channel.
100     * 
101     * @param componentChannel the channel that the component's handlers listen
102     * on by default and that {@link Manager#fire(Event, Channel...)}
103     * sends the event to
104     */
105    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
106    public VmMgmt(Channel componentChannel) {
107        super(componentChannel);
108        setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
109    }
110
111    /**
112     * Configure the component. 
113     * 
114     * @param event the event
115     */
116    @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
117    @Handler
118    public void onConfigurationUpdate(ConfigurationUpdate event) {
119        event.structured("/Manager/GuiHttpServer"
120            + "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess")
121            .ifPresent(c -> {
122                try {
123                    var dispRes = (Map<String, Object>) c
124                        .getOrDefault("displayResource",
125                            Collections.emptyMap());
126                    switch ((String) dispRes.getOrDefault("preferredIpVersion",
127                        "")) {
128                    case "ipv6":
129                        preferredIpVersion = Inet6Address.class;
130                        break;
131                    case "ipv4":
132                    default:
133                        preferredIpVersion = Inet4Address.class;
134                        break;
135                    }
136
137                    // Delete connection file
138                    deleteConnectionFile
139                        = Optional.ofNullable(c.get("deleteConnectionFile"))
140                            .filter(v -> v instanceof String)
141                            .map(v -> (String) v)
142                            .map(Boolean::parseBoolean).orElse(true);
143                } catch (ClassCastException e) {
144                    logger.config("Malformed configuration: " + e.getMessage());
145                }
146            });
147    }
148
149    /**
150     * On {@link ConsoleReady}, fire the {@link AddConletType}.
151     *
152     * @param event the event
153     * @param channel the channel
154     * @throws TemplateNotFoundException the template not found exception
155     * @throws MalformedTemplateNameException the malformed template name
156     *             exception
157     * @throws ParseException the parse exception
158     * @throws IOException Signals that an I/O exception has occurred.
159     */
160    @Handler
161    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
162            throws TemplateNotFoundException, MalformedTemplateNameException,
163            ParseException, IOException {
164        // Add conlet resources to page
165        channel.respond(new AddConletType(type())
166            .setDisplayNames(
167                localizations(channel.supportedLocales(), "conletName"))
168            .addRenderMode(RenderMode.Preview)
169            .addScript(new ScriptResource().setScriptType("module")
170                .setScriptUri(event.renderSupport().conletResource(
171                    type(), "VmMgmt-functions.js"))));
172    }
173
174    @Override
175    protected Optional<VmsModel> createStateRepresentation(Event<?> event,
176            ConsoleConnection connection, String conletId) throws Exception {
177        return Optional.of(new VmsModel(conletId));
178    }
179
180    @Override
181    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
182    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
183            ConsoleConnection channel, String conletId, VmsModel conletState)
184            throws Exception {
185        Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class);
186        boolean sendVmInfos = false;
187        if (event.renderAs().contains(RenderMode.Preview)) {
188            Template tpl
189                = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html");
190            channel.respond(new RenderConlet(type(), conletId,
191                processTemplate(event, tpl,
192                    fmModel(event, channel, conletId, conletState)))
193                        .setRenderAs(
194                            RenderMode.Preview.addModifiers(event.renderAs()))
195                        .setSupportedModes(MODES));
196            renderedAs.add(RenderMode.Preview);
197            channel.respond(new NotifyConletView(type(),
198                conletId, "summarySeries", summarySeries.entries()));
199            var summary = evaluateSummary(false);
200            channel.respond(new NotifyConletView(type(),
201                conletId, "updateSummary", summary));
202            sendVmInfos = true;
203        }
204        if (event.renderAs().contains(RenderMode.View)) {
205            Template tpl
206                = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html");
207            channel.respond(new RenderConlet(type(), conletId,
208                processTemplate(event, tpl,
209                    fmModel(event, channel, conletId, conletState)))
210                        .setRenderAs(
211                            RenderMode.View.addModifiers(event.renderAs()))
212                        .setSupportedModes(MODES));
213            renderedAs.add(RenderMode.View);
214            sendVmInfos = true;
215        }
216        if (sendVmInfos) {
217            for (var item : channelTracker.values()) {
218                updateVm(channel, conletId, item.associated());
219            }
220        }
221        return renderedAs;
222    }
223
224    private void updateVm(ConsoleConnection channel, String conletId,
225            VmDefinition vmDef) {
226        var user = WebConsoleUtils.userFromSession(channel.session())
227            .map(ConsoleUser::getName).orElse(null);
228        var roles = WebConsoleUtils.rolesFromSession(channel.session())
229            .stream().map(ConsoleRole::getName).toList();
230        channel.respond(new NotifyConletView(type(), conletId, "updateVm",
231            simplifiedVmDefinition(vmDef, user, roles)));
232    }
233
234    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
235    private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef,
236            String user, List<String> roles) {
237        // Convert RAM sizes to unitless numbers
238        var spec = DataPath.deepCopy(vmDef.spec());
239        var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get();
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        return Map.of("metadata",
253            Map.of("namespace", vmDef.namespace(),
254                "name", vmDef.name()),
255            "spec", spec,
256            "status", status,
257            "nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""),
258            "permissions", vmDef.permissionsFor(user, roles).stream()
259                .map(VmDefinition.Permission::toString).toList());
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.ConfusingTernary", "PMD.CognitiveComplexity",
271        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
272        "PMD.ConfusingArgumentToVarargsMethod" })
273    public void onVmDefChanged(VmDefChanged 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    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
379        "PMD.LambdaCanBeMethodReference" })
380    private Summary evaluateSummary(boolean force) {
381        if (!force && cachedSummary != null) {
382            return cachedSummary;
383        }
384        Summary summary = new Summary();
385        for (var vmDef : channelTracker.associated()) {
386            summary.totalVms += 1;
387            summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
388                .map(Number::intValue).orElse(0);
389            summary.usedRam = summary.usedRam
390                .add(vmDef.<String> fromStatus(Status.RAM)
391                    .map(r -> Quantity.fromString(r).getNumber().toBigInteger())
392                    .orElse(BigInteger.ZERO));
393            if (vmDef.conditionStatus("Running").orElse(false)) {
394                summary.runningVms += 1;
395            }
396        }
397        cachedSummary = summary;
398        return summary;
399    }
400
401    @Override
402    @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor",
403        "PMD.NcssCount" })
404    protected void doUpdateConletState(NotifyConletModel event,
405            ConsoleConnection channel, VmsModel model) throws Exception {
406        event.stop();
407        String vmName = event.param(0);
408        var value = channelTracker.value(vmName);
409        var vmChannel = value.map(v -> v.channel()).orElse(null);
410        var vmDef = value.map(v -> v.associated()).orElse(null);
411        if (vmDef == null) {
412            return;
413        }
414        var user = WebConsoleUtils.userFromSession(channel.session())
415            .map(ConsoleUser::getName).orElse("");
416        var roles = WebConsoleUtils.rolesFromSession(channel.session())
417            .stream().map(ConsoleRole::getName).toList();
418        var perms = vmDef.permissionsFor(user, roles);
419        switch (event.method()) {
420        case "start":
421            if (perms.contains(VmDefinition.Permission.START)) {
422                fire(new ModifyVm(vmName, "state", "Running", vmChannel));
423            }
424            break;
425        case "stop":
426            if (perms.contains(VmDefinition.Permission.STOP)) {
427                fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
428            }
429            break;
430        case "reset":
431            if (perms.contains(VmDefinition.Permission.RESET)) {
432                confirmReset(event, channel, model, vmName);
433            }
434            break;
435        case "resetConfirmed":
436            if (perms.contains(VmDefinition.Permission.RESET)) {
437                fire(new ResetVm(vmName), vmChannel);
438            }
439            break;
440        case "openConsole":
441            if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
442                openConsole(channel, model, vmChannel, vmDef, user, perms);
443            }
444            break;
445        case "cpus":
446            fire(new ModifyVm(vmName, "currentCpus",
447                new BigDecimal(event.param(1).toString()).toBigInteger(),
448                vmChannel));
449            break;
450        case "ram":
451            fire(new ModifyVm(vmName, "currentRam",
452                new Quantity(new BigDecimal(event.param(1).toString()),
453                    Format.BINARY_SI).toSuffixedString(),
454                vmChannel));
455            break;
456        default:// ignore
457            break;
458        }
459    }
460
461    private void confirmReset(NotifyConletModel event,
462            ConsoleConnection channel, VmsModel model, String vmName)
463            throws TemplateNotFoundException,
464            MalformedTemplateNameException, ParseException, IOException {
465        Template tpl = freemarkerConfig()
466            .getTemplate("VmMgmt-confirmReset.ftl.html");
467        ResourceBundle resourceBundle = resourceBundle(channel.locale());
468        var fmModel = fmModel(event, channel, model.getConletId(), model);
469        fmModel.put("vmName", vmName);
470        channel.respond(new OpenModalDialog(type(), model.getConletId(),
471            processTemplate(event, tpl, fmModel))
472                .addOption("cancelable", true).addOption("closeLabel", "")
473                .addOption("title",
474                    resourceBundle.getString("confirmResetTitle")));
475    }
476
477    private void openConsole(ConsoleConnection channel, VmsModel model,
478            VmChannel vmChannel, VmDefinition vmDef, String user,
479            Set<Permission> perms) {
480        ResourceBundle resourceBundle = resourceBundle(channel.locale());
481        if (!vmDef.consoleAccessible(user, perms)) {
482            channel.respond(new DisplayNotification(
483                resourceBundle.getString("consoleTakenNotification"),
484                Map.of("autoClose", 5_000, "type", "Warning")));
485            return;
486        }
487        var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
488            e -> gotPassword(channel, model, vmDef, e));
489        fire(pwQuery, vmChannel);
490    }
491
492    private void gotPassword(ConsoleConnection channel, VmsModel model,
493            VmDefinition vmDef, PrepareConsole event) {
494        if (!event.passwordAvailable()) {
495            return;
496        }
497        vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
498            preferredIpVersion, deleteConnectionFile)).ifPresent(
499                cf -> channel.respond(new NotifyConletView(type(),
500                    model.getConletId(), "openConsole", cf)));
501    }
502
503    @Override
504    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
505            String conletId) throws Exception {
506        return true;
507    }
508
509    /**
510     * The Class VmsModel.
511     */
512    public class VmsModel extends ConletBaseModel {
513
514        /**
515         * Instantiates a new vms model.
516         *
517         * @param conletId the conlet id
518         */
519        public VmsModel(String conletId) {
520            super(conletId);
521        }
522
523    }
524}