001/*
002 * VM-Operator
003 * Copyright (C) 2023,2025 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.vmaccess;
020
021import com.fasterxml.jackson.annotation.JsonGetter;
022import com.fasterxml.jackson.annotation.JsonProperty;
023import com.fasterxml.jackson.core.JsonProcessingException;
024import com.fasterxml.jackson.databind.ObjectMapper;
025import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
026import com.google.gson.JsonSyntaxException;
027import freemarker.core.ParseException;
028import freemarker.template.MalformedTemplateNameException;
029import freemarker.template.Template;
030import freemarker.template.TemplateNotFoundException;
031import io.kubernetes.client.util.Strings;
032import java.io.IOException;
033import java.net.Inet4Address;
034import java.net.Inet6Address;
035import java.time.Duration;
036import java.util.Collections;
037import java.util.EnumSet;
038import java.util.HashSet;
039import java.util.List;
040import java.util.Map;
041import java.util.Optional;
042import java.util.ResourceBundle;
043import java.util.Set;
044import java.util.logging.Level;
045import java.util.stream.Collectors;
046import org.bouncycastle.util.Objects;
047import org.jdrupes.vmoperator.common.K8sObserver;
048import org.jdrupes.vmoperator.common.VmDefinition;
049import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
050import org.jdrupes.vmoperator.common.VmDefinition.Permission;
051import org.jdrupes.vmoperator.common.VmPool;
052import org.jdrupes.vmoperator.manager.events.AssignVm;
053import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
054import org.jdrupes.vmoperator.manager.events.GetPools;
055import org.jdrupes.vmoperator.manager.events.GetVms;
056import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
057import org.jdrupes.vmoperator.manager.events.ModifyVm;
058import org.jdrupes.vmoperator.manager.events.ResetVm;
059import org.jdrupes.vmoperator.manager.events.VmChannel;
060import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
061import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
062import org.jgrapes.core.Channel;
063import org.jgrapes.core.Components;
064import org.jgrapes.core.Event;
065import org.jgrapes.core.EventPipeline;
066import org.jgrapes.core.Manager;
067import org.jgrapes.core.annotation.Handler;
068import org.jgrapes.core.events.Start;
069import org.jgrapes.http.Session;
070import org.jgrapes.util.events.ConfigurationUpdate;
071import org.jgrapes.util.events.KeyValueStoreQuery;
072import org.jgrapes.util.events.KeyValueStoreUpdate;
073import org.jgrapes.webconsole.base.Conlet.RenderMode;
074import org.jgrapes.webconsole.base.ConletBaseModel;
075import org.jgrapes.webconsole.base.ConsoleConnection;
076import org.jgrapes.webconsole.base.ConsoleRole;
077import org.jgrapes.webconsole.base.ConsoleUser;
078import org.jgrapes.webconsole.base.WebConsoleUtils;
079import org.jgrapes.webconsole.base.events.AddConletRequest;
080import org.jgrapes.webconsole.base.events.AddConletType;
081import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
082import org.jgrapes.webconsole.base.events.ConletDeleted;
083import org.jgrapes.webconsole.base.events.ConsoleConfigured;
084import org.jgrapes.webconsole.base.events.ConsolePrepared;
085import org.jgrapes.webconsole.base.events.ConsoleReady;
086import org.jgrapes.webconsole.base.events.DeleteConlet;
087import org.jgrapes.webconsole.base.events.DisplayNotification;
088import org.jgrapes.webconsole.base.events.NotifyConletModel;
089import org.jgrapes.webconsole.base.events.NotifyConletView;
090import org.jgrapes.webconsole.base.events.OpenModalDialog;
091import org.jgrapes.webconsole.base.events.RenderConlet;
092import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
093import org.jgrapes.webconsole.base.events.SetLocale;
094import org.jgrapes.webconsole.base.events.UpdateConletType;
095import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
096
097/**
098 * The Class {@link VmAccess}. The component supports the following
099 * configuration properties:
100 * 
101 *   * `displayResource`: a map with the following entries:
102 *       - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`).
103 *         Determines the IP addresses uses in the generated
104 *         connection file.
105 *   * `deleteConnectionFile`: `true` or `false` (default: `true`).
106 *     If `true`, the downloaded connection file will be deleted by
107 *     the remote viewer when opened.
108 *   * `syncPreviewsFor`: a list objects with either property `user` or
109 *     `role` and the associated name (default: `[]`).
110 *     The remote viewer will synchronize the previews for the specified
111 *     users and roles.
112 *
113 */
114@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects",
115    "PMD.GodClass", "PMD.TooManyMethods", "PMD.CyclomaticComplexity" })
116public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
117
118    private static final String VM_NAME_PROPERTY = "vmName";
119    private static final String POOL_NAME_PROPERTY = "poolName";
120    private static final String RENDERED
121        = VmAccess.class.getName() + ".rendered";
122    private static final String PENDING
123        = VmAccess.class.getName() + ".pending";
124    private static final Set<RenderMode> MODES = RenderMode.asSet(
125        RenderMode.Preview, RenderMode.Edit);
126    private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
127        RenderMode.Preview, RenderMode.StickyPreview);
128    private EventPipeline appPipeline;
129    private static ObjectMapper objectMapper
130        = new ObjectMapper().registerModule(new JavaTimeModule());
131
132    private Class<?> preferredIpVersion = Inet4Address.class;
133    private Set<String> syncUsers = Collections.emptySet();
134    private Set<String> syncRoles = Collections.emptySet();
135    private boolean deleteConnectionFile = true;
136
137    /**
138     * The periodically generated update event.
139     */
140    public static class Update extends Event<Void> {
141    }
142
143    /**
144     * Creates a new component with its channel set to the given channel.
145     * 
146     * @param componentChannel the channel that the component's handlers listen
147     * on by default and that {@link Manager#fire(Event, Channel...)}
148     * sends the event to
149     */
150    public VmAccess(Channel componentChannel) {
151        super(componentChannel);
152    }
153
154    /**
155     * On start.
156     *
157     * @param event the event
158     */
159    @Handler
160    public void onStart(Start event) {
161        appPipeline = event.processedBy().get();
162    }
163
164    /**
165     * Configure the component. 
166     * 
167     * @param event the event
168     */
169    @SuppressWarnings({ "unchecked" })
170    @Handler
171    public void onConfigurationUpdate(ConfigurationUpdate event) {
172        event.structured(componentPath())
173            .or(() -> {
174                var oldConfig = event.structured("/Manager/GuiHttpServer"
175                    + "/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer");
176                if (oldConfig.isPresent()) {
177                    logger.warning(() -> "Using configuration with old "
178                        + "component name \"VmViewer\", please update to "
179                        + "\"VmAccess\"");
180                }
181                return oldConfig;
182            })
183            .ifPresent(c -> {
184                try {
185                    var dispRes = (Map<String, Object>) c
186                        .getOrDefault("displayResource",
187                            Collections.emptyMap());
188                    switch ((String) dispRes.getOrDefault("preferredIpVersion",
189                        "")) {
190                    case "ipv6":
191                        preferredIpVersion = Inet6Address.class;
192                        break;
193                    case "ipv4":
194                    default:
195                        preferredIpVersion = Inet4Address.class;
196                        break;
197                    }
198
199                    // Delete connection file
200                    deleteConnectionFile
201                        = Optional.ofNullable(c.get("deleteConnectionFile"))
202                            .map(Object::toString).map(Boolean::parseBoolean)
203                            .orElse(true);
204
205                    // Users or roles for which previews should be synchronized
206                    syncUsers = ((List<Map<String, String>>) c.getOrDefault(
207                        "syncPreviewsFor", Collections.emptyList())).stream()
208                            .map(m -> m.get("user"))
209                            .filter(s -> s != null).collect(Collectors.toSet());
210                    logger.finest(() -> "Syncing previews for users: "
211                        + syncUsers.toString());
212                    syncRoles = ((List<Map<String, String>>) c.getOrDefault(
213                        "syncPreviewsFor", Collections.emptyList())).stream()
214                            .map(m -> m.get("role"))
215                            .filter(s -> s != null).collect(Collectors.toSet());
216                    logger.finest(() -> "Syncing previews for roles: "
217                        + syncRoles.toString());
218                } catch (ClassCastException e) {
219                    logger.config("Malformed configuration: " + e.getMessage());
220                }
221            });
222    }
223
224    private boolean syncPreviews(Session session) {
225        return WebConsoleUtils.userFromSession(session)
226            .filter(u -> syncUsers.contains(u.getName())).isPresent()
227            || WebConsoleUtils.rolesFromSession(session).stream()
228                .filter(cr -> syncRoles.contains(cr.getName())).findAny()
229                .isPresent();
230    }
231
232    /**
233     * On {@link ConsoleReady}, fire the {@link AddConletType}.
234     *
235     * @param event the event
236     * @param channel the channel
237     * @throws TemplateNotFoundException the template not found exception
238     * @throws MalformedTemplateNameException the malformed template name
239     *             exception
240     * @throws ParseException the parse exception
241     * @throws IOException Signals that an I/O exception has occurred.
242     */
243    @Handler
244    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
245            throws TemplateNotFoundException, MalformedTemplateNameException,
246            ParseException, IOException {
247        // Add conlet resources to page
248        channel.respond(new AddConletType(type())
249            .setDisplayNames(
250                localizations(channel.supportedLocales(), "conletName"))
251            .addRenderMode(RenderMode.Preview)
252            .addScript(new ScriptResource().setScriptType("module")
253                .setScriptUri(event.renderSupport().conletResource(
254                    type(), "VmAccess-functions.js"))));
255        channel.session().put(RENDERED, new HashSet<>());
256    }
257
258    /**
259     * On console configured.
260     *
261     * @param event the event
262     * @param connection the console connection
263     * @throws InterruptedException the interrupted exception
264     */
265    @Handler
266    public void onConsoleConfigured(ConsoleConfigured event,
267            ConsoleConnection connection) throws InterruptedException,
268            IOException {
269        @SuppressWarnings({ "unchecked" })
270        final var rendered
271            = (Set<ResourceModel>) connection.session().get(RENDERED);
272        connection.session().remove(RENDERED);
273        if (!syncPreviews(connection.session())) {
274            return;
275        }
276        addMissingConlets(event, connection, rendered);
277    }
278
279    @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" })
280    private void addMissingConlets(ConsoleConfigured event,
281            ConsoleConnection connection, final Set<ResourceModel> rendered)
282            throws InterruptedException {
283        var session = connection.session();
284
285        // Evaluate missing VMs
286        var missingVms = appPipeline.fire(new GetVms().accessibleFor(
287            WebConsoleUtils.userFromSession(session)
288                .map(ConsoleUser::getName).orElse(null),
289            WebConsoleUtils.rolesFromSession(session).stream()
290                .map(ConsoleRole::getName).toList()))
291            .get().stream().map(d -> d.definition().name())
292            .collect(Collectors.toCollection(HashSet::new));
293        missingVms.removeAll(rendered.stream()
294            .filter(r -> r.mode() == ResourceModel.Mode.VM)
295            .map(ResourceModel::name).toList());
296
297        // Evaluate missing pools
298        var missingPools = appPipeline.fire(new GetPools().accessibleFor(
299            WebConsoleUtils.userFromSession(session)
300                .map(ConsoleUser::getName).orElse(null),
301            WebConsoleUtils.rolesFromSession(session).stream()
302                .map(ConsoleRole::getName).toList()))
303            .get().stream().map(VmPool::name)
304            .collect(Collectors.toCollection(HashSet::new));
305        missingPools.removeAll(rendered.stream()
306            .filter(r -> r.mode() == ResourceModel.Mode.POOL)
307            .map(ResourceModel::name).toList());
308
309        // Nothing to do
310        if (missingVms.isEmpty() && missingPools.isEmpty()) {
311            return;
312        }
313
314        // Suspending to allow rendering of conlets to be noticed
315        var failSafe = Components.schedule(t -> event.resumeHandling(),
316            Duration.ofSeconds(1));
317        event.suspendHandling(failSafe::cancel);
318        connection.setAssociated(PENDING, event);
319
320        // Create conlets for VMs and pools that haven't been rendered
321        for (var vmName : missingVms) {
322            fire(new AddConletRequest(event.event().event().renderSupport(),
323                VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview))
324                    .addProperty(VM_NAME_PROPERTY, vmName),
325                connection);
326        }
327        for (var poolName : missingPools) {
328            fire(new AddConletRequest(event.event().event().renderSupport(),
329                VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview))
330                    .addProperty(POOL_NAME_PROPERTY, poolName),
331                connection);
332        }
333    }
334
335    /**
336     * On console prepared.
337     *
338     * @param event the event
339     * @param connection the connection
340     */
341    @Handler
342    public void onConsolePrepared(ConsolePrepared event,
343            ConsoleConnection connection) {
344        if (syncPreviews(connection.session())) {
345            connection.respond(new UpdateConletType(type()));
346        }
347    }
348
349    private String storagePath(Session session, String conletId) {
350        return "/" + WebConsoleUtils.userFromSession(session)
351            .map(ConsoleUser::getName).orElse("")
352            + "/" + VmAccess.class.getName() + "/" + conletId;
353    }
354
355    @Override
356    protected Optional<ResourceModel> createNewState(AddConletRequest event,
357            ConsoleConnection connection, String conletId) throws Exception {
358        var model = new ResourceModel(conletId);
359        var poolName = (String) event.properties().get(POOL_NAME_PROPERTY);
360        if (poolName != null) {
361            model.setMode(ResourceModel.Mode.POOL);
362            model.setName(poolName);
363        } else {
364            model.setMode(ResourceModel.Mode.VM);
365            model.setName((String) event.properties().get(VM_NAME_PROPERTY));
366        }
367        String jsonState = objectMapper.writeValueAsString(model);
368        connection.respond(new KeyValueStoreUpdate().update(
369            storagePath(connection.session(), model.getConletId()), jsonState));
370        return Optional.of(model);
371    }
372
373    @Override
374    protected Optional<ResourceModel> createStateRepresentation(Event<?> event,
375            ConsoleConnection connection, String conletId) throws Exception {
376        var model = new ResourceModel(conletId);
377        String jsonState = objectMapper.writeValueAsString(model);
378        connection.respond(new KeyValueStoreUpdate().update(
379            storagePath(connection.session(), model.getConletId()), jsonState));
380        return Optional.of(model);
381    }
382
383    @Override
384    @SuppressWarnings("PMD.EmptyCatchBlock")
385    protected Optional<ResourceModel> recreateState(Event<?> event,
386            ConsoleConnection channel, String conletId) throws Exception {
387        KeyValueStoreQuery query = new KeyValueStoreQuery(
388            storagePath(channel.session(), conletId), channel);
389        newEventPipeline().fire(query, channel);
390        try {
391            if (!query.results().isEmpty()) {
392                var json = query.results().get(0).values().stream().findFirst()
393                    .get();
394                ResourceModel model
395                    = objectMapper.readValue(json, ResourceModel.class);
396                return Optional.of(model);
397            }
398        } catch (InterruptedException e) {
399            // Means we have no result.
400        }
401
402        // Fall back to creating default state.
403        return createStateRepresentation(event, channel, conletId);
404    }
405
406    @Override
407    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
408            ConsoleConnection channel, String conletId, ResourceModel model)
409            throws Exception {
410        if (event.renderAs().contains(RenderMode.Preview)) {
411            return renderPreview(event, channel, conletId, model);
412        }
413
414        // Render edit
415        ResourceBundle resourceBundle = resourceBundle(channel.locale());
416        Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class);
417        if (event.renderAs().contains(RenderMode.Edit)) {
418            var session = channel.session();
419            var vmNames = appPipeline.fire(new GetVms().accessibleFor(
420                WebConsoleUtils.userFromSession(session)
421                    .map(ConsoleUser::getName).orElse(null),
422                WebConsoleUtils.rolesFromSession(session).stream()
423                    .map(ConsoleRole::getName).toList()))
424                .get().stream().map(d -> d.definition().name()).sorted()
425                .toList();
426            var poolNames = appPipeline.fire(new GetPools().accessibleFor(
427                WebConsoleUtils.userFromSession(session)
428                    .map(ConsoleUser::getName).orElse(null),
429                WebConsoleUtils.rolesFromSession(session).stream()
430                    .map(ConsoleRole::getName).toList()))
431                .get().stream().map(VmPool::name).sorted().toList();
432            Template tpl
433                = freemarkerConfig().getTemplate("VmAccess-edit.ftl.html");
434            var fmModel = fmModel(event, channel, conletId, model);
435            fmModel.put("vmNames", vmNames);
436            fmModel.put("poolNames", poolNames);
437            channel.respond(new OpenModalDialog(type(), conletId,
438                processTemplate(event, tpl, fmModel))
439                    .addOption("cancelable", true)
440                    .addOption("okayLabel",
441                        resourceBundle.getString("okayLabel")));
442        }
443        return renderedAs;
444    }
445
446    @SuppressWarnings("unchecked")
447    private Set<RenderMode> renderPreview(RenderConletRequestBase<?> event,
448            ConsoleConnection channel, String conletId, ResourceModel model)
449            throws TemplateNotFoundException, MalformedTemplateNameException,
450            ParseException, IOException, InterruptedException {
451        channel.associated(PENDING, Event.class)
452            .ifPresent(e -> {
453                e.resumeHandling();
454                channel.setAssociated(PENDING, null);
455            });
456
457        VmDefinition vmDef = null;
458        if (model.mode() == ResourceModel.Mode.VM && model.name() != null) {
459            // Remove conlet if VM definition has been removed
460            // or user has not at least one permission
461            vmDef = getVmData(model, channel).map(VmData::definition)
462                .orElse(null);
463            if (vmDef == null) {
464                channel.respond(
465                    new DeleteConlet(conletId, Collections.emptySet()));
466                return Collections.emptySet();
467            }
468        }
469
470        if (model.mode() == ResourceModel.Mode.POOL && model.name() != null) {
471            // Remove conlet if pool definition has been removed
472            // or user has not at least one permission
473            VmPool pool = appPipeline
474                .fire(new GetPools().withName(model.name())).get()
475                .stream().findFirst().orElse(null);
476            if (pool == null
477                || permissions(pool, channel.session()).isEmpty()) {
478                channel.respond(
479                    new DeleteConlet(conletId, Collections.emptySet()));
480                return Collections.emptySet();
481            }
482            vmDef = getVmData(model, channel).map(VmData::definition)
483                .orElse(null);
484        }
485
486        // Render
487        Template tpl
488            = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html");
489        channel.respond(new RenderConlet(type(), conletId,
490            processTemplate(event, tpl,
491                fmModel(event, channel, conletId, model)))
492                    .setRenderAs(
493                        RenderMode.Preview.addModifiers(event.renderAs()))
494                    .setSupportedModes(syncPreviews(channel.session())
495                        ? MODES_FOR_GENERATED
496                        : MODES));
497        if (!Strings.isNullOrEmpty(model.name())) {
498            Optional.ofNullable(channel.session().get(RENDERED))
499                .ifPresent(s -> ((Set<ResourceModel>) s).add(model));
500            updatePreview(channel, model, vmDef);
501        }
502        return EnumSet.of(RenderMode.Preview);
503    }
504
505    private Optional<VmData> getVmData(ResourceModel model,
506            ConsoleConnection channel) throws InterruptedException {
507        if (model.mode() == ResourceModel.Mode.VM) {
508            // Get the VM data by name.
509            var session = channel.session();
510            return appPipeline.fire(new GetVms().withName(model.name())
511                .accessibleFor(WebConsoleUtils.userFromSession(session)
512                    .map(ConsoleUser::getName).orElse(null),
513                    WebConsoleUtils.rolesFromSession(session).stream()
514                        .map(ConsoleRole::getName).toList()))
515                .get().stream().findFirst();
516        }
517
518        // Look for an (already) assigned VM
519        var user = WebConsoleUtils.userFromSession(channel.session())
520            .map(ConsoleUser::getName).orElse(null);
521        return appPipeline.fire(new GetVms().assignedFrom(model.name())
522            .assignedTo(user)).get().stream().findFirst();
523    }
524
525    /**
526     * Returns the permissions from the VM definition.
527     *
528     * @param vmDef the VM definition
529     * @param session the session
530     * @return the sets the
531     */
532    private Set<Permission> permissions(VmDefinition vmDef, Session session) {
533        var user = WebConsoleUtils.userFromSession(session)
534            .map(ConsoleUser::getName).orElse(null);
535        var roles = WebConsoleUtils.rolesFromSession(session)
536            .stream().map(ConsoleRole::getName).toList();
537        return vmDef.permissionsFor(user, roles);
538    }
539
540    /**
541     * Returns the permissions from the pool.
542     *
543     * @param pool the pool
544     * @param session the session
545     * @return the sets the
546     */
547    private Set<Permission> permissions(VmPool pool, Session session) {
548        var user = WebConsoleUtils.userFromSession(session)
549            .map(ConsoleUser::getName).orElse(null);
550        var roles = WebConsoleUtils.rolesFromSession(session)
551            .stream().map(ConsoleRole::getName).toList();
552        return pool.permissionsFor(user, roles);
553    }
554
555    /**
556     * Returns the permissions from the VM definition or the pool depending
557     * on the state of the model.
558     *
559     * @param session the session
560     * @param model the model
561     * @param vmDef the vm def
562     * @return the sets the
563     * @throws InterruptedException the interrupted exception
564     */
565    private Set<Permission> permissions(Session session, ResourceModel model,
566            VmDefinition vmDef) throws InterruptedException {
567        var user = WebConsoleUtils.userFromSession(session)
568            .map(ConsoleUser::getName).orElse(null);
569        var roles = WebConsoleUtils.rolesFromSession(session)
570            .stream().map(ConsoleRole::getName).toList();
571        if (model.mode() == ResourceModel.Mode.POOL) {
572            // Use permissions from pool
573            var pool = appPipeline.fire(new GetPools().withName(model.name()))
574                .get().stream().findFirst().orElse(null);
575            if (pool == null) {
576                return Collections.emptySet();
577            }
578            return pool.permissionsFor(user, roles);
579        }
580
581        // Use permissions from VM
582        if (vmDef == null) {
583            vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name())
584                .assignedTo(user)).get().stream().map(VmData::definition)
585                .findFirst().orElse(null);
586        }
587        if (vmDef == null) {
588            return Collections.emptySet();
589        }
590        return vmDef.permissionsFor(user, roles);
591    }
592
593    private void updatePreview(ConsoleConnection channel, ResourceModel model,
594            VmDefinition vmDef) throws InterruptedException {
595        updateConfig(channel, model, vmDef);
596        updateVmDef(channel, model, vmDef);
597    }
598
599    private void updateConfig(ConsoleConnection channel, ResourceModel model,
600            VmDefinition vmDef) throws InterruptedException {
601        channel.respond(new NotifyConletView(type(),
602            model.getConletId(), "updateConfig", model.mode(), model.name(),
603            permissions(channel.session(), model, vmDef).stream()
604                .map(VmDefinition.Permission::toString).toList()));
605    }
606
607    private void updateVmDef(ConsoleConnection channel, ResourceModel model,
608            VmDefinition vmDef) throws InterruptedException {
609        Map<String, Object> data = null;
610        if (vmDef == null) {
611            model.setAssignedVm(null);
612        } else {
613            model.setAssignedVm(vmDef.name());
614            var session = channel.session();
615            var user = WebConsoleUtils.userFromSession(session)
616                .map(ConsoleUser::getName).orElse(null);
617            var perms = permissions(session, model, vmDef);
618            try {
619                data = Map.of(
620                    "metadata", Map.of("namespace", vmDef.namespace(),
621                        "name", vmDef.name()),
622                    "spec", vmDef.spec(),
623                    "status", vmDef.status(),
624                    "consoleAccessible", vmDef.consoleAccessible(user, perms));
625            } catch (JsonSyntaxException e) {
626                logger.log(Level.SEVERE, e,
627                    () -> "Failed to serialize VM definition");
628                return;
629            }
630        }
631        channel.respond(new NotifyConletView(type(),
632            model.getConletId(), "updateVmDefinition", data));
633    }
634
635    @Override
636    protected void doConletDeleted(ConletDeleted event,
637            ConsoleConnection channel, String conletId,
638            ResourceModel conletState)
639            throws Exception {
640        if (event.renderModes().isEmpty()) {
641            channel.respond(new KeyValueStoreUpdate().delete(
642                storagePath(channel.session(), conletId)));
643        }
644    }
645
646    /**
647     * Track the VM definitions and update conlets.
648     *
649     * @param event the event
650     * @param channel the channel
651     * @throws IOException 
652     * @throws InterruptedException 
653     */
654    @Handler(namedChannels = "manager")
655    @SuppressWarnings({ "PMD.CognitiveComplexity",
656        "PMD.AvoidInstantiatingObjectsInLoops" })
657    public void onVmResourceChanged(VmResourceChanged event, VmChannel channel)
658            throws IOException, InterruptedException {
659        var vmDef = event.vmDefinition();
660
661        // Update known conlets
662        for (var entry : conletIdsByConsoleConnection().entrySet()) {
663            var connection = entry.getKey();
664            var user = WebConsoleUtils.userFromSession(connection.session())
665                .map(ConsoleUser::getName).orElse(null);
666            for (var conletId : entry.getValue()) {
667                var model = stateFromSession(connection.session(), conletId);
668                if (model.isEmpty()
669                    || Strings.isNullOrEmpty(model.get().name())) {
670                    continue;
671                }
672                if (model.get().mode() == ResourceModel.Mode.VM) {
673                    // Check if this VM is used by conlet
674                    if (!Objects.areEqual(model.get().name(), vmDef.name())) {
675                        continue;
676                    }
677                    if (event.type() == K8sObserver.ResponseType.DELETED
678                        || permissions(vmDef, connection.session()).isEmpty()) {
679                        connection.respond(
680                            new DeleteConlet(conletId, Collections.emptySet()));
681                        continue;
682                    }
683                } else {
684                    // Check if VM is used by pool conlet or to be assigned to
685                    // it
686                    var toBeUsedByConlet = vmDef.assignment()
687                        .map(Assignment::pool)
688                        .map(p -> p.equals(model.get().name())).orElse(false)
689                        && vmDef.assignment().map(Assignment::user)
690                            .map(u -> u.equals(user)).orElse(false);
691                    if (!Objects.areEqual(model.get().assignedVm(),
692                        vmDef.name()) && !toBeUsedByConlet) {
693                        continue;
694                    }
695
696                    // Now unassigned if VM is deleted or no longer to be used
697                    if (event.type() == K8sObserver.ResponseType.DELETED
698                        || !toBeUsedByConlet) {
699                        updateVmDef(connection, model.get(), null);
700                        continue;
701                    }
702                }
703
704                // Full update because permissions may have changed
705                updatePreview(connection, model.get(), vmDef);
706            }
707        }
708    }
709
710    /**
711     * On vm pool changed.
712     *
713     * @param event the event
714     * @throws InterruptedException the interrupted exception
715     */
716    @Handler(namedChannels = "manager")
717    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
718    public void onVmPoolChanged(VmPoolChanged event)
719            throws InterruptedException {
720        var poolName = event.vmPool().name();
721        // Update known conlets
722        for (var entry : conletIdsByConsoleConnection().entrySet()) {
723            var connection = entry.getKey();
724            for (var conletId : entry.getValue()) {
725                var model = stateFromSession(connection.session(), conletId);
726                if (model.isEmpty()
727                    || model.get().mode() != ResourceModel.Mode.POOL
728                    || !Objects.areEqual(model.get().name(), poolName)) {
729                    continue;
730                }
731                if (event.deleted()
732                    || permissions(event.vmPool(), connection.session())
733                        .isEmpty()) {
734                    connection.respond(
735                        new DeleteConlet(conletId, Collections.emptySet()));
736                    continue;
737                }
738                updateConfig(connection, model.get(), null);
739            }
740        }
741    }
742
743    @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity",
744        "PMD.AvoidLiteralsInIfCondition" })
745    @Override
746    protected void doUpdateConletState(NotifyConletModel event,
747            ConsoleConnection channel, ResourceModel model) throws Exception {
748        event.stop();
749        if ("selectedResource".equals(event.method())) {
750            selectResource(event, channel, model);
751            return;
752        }
753
754        Optional<VmData> vmData = getVmData(model, channel);
755        if (vmData.isEmpty()) {
756            if (model.mode() == ResourceModel.Mode.VM) {
757                return;
758            }
759            if ("start".equals(event.method())) {
760                // Assign a VM.
761                var user = WebConsoleUtils.userFromSession(channel.session())
762                    .map(ConsoleUser::getName).orElse(null);
763                vmData = Optional.ofNullable(appPipeline
764                    .fire(new AssignVm(model.name(), user)).get());
765                if (vmData.isEmpty()) {
766                    ResourceBundle resourceBundle
767                        = resourceBundle(channel.locale());
768                    channel.respond(new DisplayNotification(
769                        resourceBundle.getString("poolEmptyNotification"),
770                        Map.of("autoClose", 10_000, "type", "Error")));
771                    return;
772                }
773            }
774        }
775
776        // Handle command for selected VM
777        var vmChannel = vmData.get().channel();
778        var vmDef = vmData.get().definition();
779        var vmName = vmDef.metadata().getName();
780        var perms = permissions(channel.session(), model, vmDef);
781        var resourceBundle = resourceBundle(channel.locale());
782        switch (event.method()) {
783        case "start":
784            if (perms.contains(VmDefinition.Permission.START)) {
785                vmChannel.fire(new ModifyVm(vmName, "state", "Running"));
786            }
787            break;
788        case "stop":
789            if (perms.contains(VmDefinition.Permission.STOP)) {
790                vmChannel.fire(new ModifyVm(vmName, "state", "Stopped"));
791            }
792            break;
793        case "reset":
794            if (perms.contains(VmDefinition.Permission.RESET)) {
795                confirmReset(event, channel, model, resourceBundle);
796            }
797            break;
798        case "resetConfirmed":
799            if (perms.contains(VmDefinition.Permission.RESET)) {
800                vmChannel.fire(new ResetVm(vmName));
801            }
802            break;
803        case "openConsole":
804            openConsole(channel, model, vmChannel, vmDef, perms);
805            break;
806        default:// ignore
807            break;
808        }
809    }
810
811    private void confirmReset(NotifyConletModel event,
812            ConsoleConnection channel, ResourceModel model,
813            ResourceBundle resourceBundle) throws TemplateNotFoundException,
814            MalformedTemplateNameException, ParseException, IOException {
815        Template tpl = freemarkerConfig()
816            .getTemplate("VmAccess-confirmReset.ftl.html");
817        channel.respond(new OpenModalDialog(type(), model.getConletId(),
818            processTemplate(event, tpl,
819                fmModel(event, channel, model.getConletId(), model)))
820                    .addOption("cancelable", true).addOption("closeLabel", "")
821                    .addOption("title",
822                        resourceBundle.getString("confirmResetTitle")));
823    }
824
825    private void openConsole(ConsoleConnection channel, ResourceModel model,
826            VmChannel vmChannel, VmDefinition vmDef, Set<Permission> perms) {
827        var resourceBundle = resourceBundle(channel.locale());
828        var user = WebConsoleUtils.userFromSession(channel.session())
829            .map(ConsoleUser::getName).orElse("");
830        if (!vmDef.consoleAccessible(user, perms)) {
831            channel.respond(new DisplayNotification(
832                resourceBundle.getString("consoleInaccessibleNotification"),
833                Map.of("autoClose", 5_000, "type", "Warning")));
834            return;
835        }
836        var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
837            e -> gotPassword(channel, model, vmDef, e));
838        vmChannel.fire(pwQuery);
839    }
840
841    private void gotPassword(ConsoleConnection channel, ResourceModel model,
842            VmDefinition vmDef, GetDisplaySecret event) {
843        if (!event.secretAvailable()) {
844            return;
845        }
846        vmDef.extra().connectionFile(event.secret(),
847            preferredIpVersion, deleteConnectionFile)
848            .ifPresent(cf -> channel.respond(new NotifyConletView(type(),
849                model.getConletId(), "openConsole", cf)));
850    }
851
852    @SuppressWarnings({ "PMD.UseLocaleWithCaseConversions" })
853    private void selectResource(NotifyConletModel event,
854            ConsoleConnection channel, ResourceModel model)
855            throws JsonProcessingException, InterruptedException {
856        try {
857            model.setMode(ResourceModel.Mode
858                .valueOf(event.<String> param(0).toUpperCase()));
859            model.setName(event.param(1));
860            String jsonState = objectMapper.writeValueAsString(model);
861            channel.respond(new KeyValueStoreUpdate().update(storagePath(
862                channel.session(), model.getConletId()), jsonState));
863            updatePreview(channel, model,
864                getVmData(model, channel).map(VmData::definition).orElse(null));
865        } catch (IllegalArgumentException e) {
866            logger.warning(() -> "Invalid resource type: " + e.getMessage());
867        }
868    }
869
870    @Override
871    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
872            String conletId) throws Exception {
873        return true;
874    }
875
876    /**
877     * The Class AccessModel.
878     */
879    public static class ResourceModel extends ConletBaseModel {
880
881        /**
882         * The Enum ResourceType.
883         */
884        @SuppressWarnings("PMD.ShortVariable")
885        public enum Mode {
886            VM, POOL
887        }
888
889        private Mode mode;
890        private String name;
891        private String assignedVm;
892
893        /**
894         * Instantiates a new resource model.
895         *
896         * @param conletId the conlet id
897         */
898        public ResourceModel(@JsonProperty("conletId") String conletId) {
899            super(conletId);
900        }
901
902        /**
903         * Returns the mode.
904         *
905         * @return the resourceType
906         */
907        @JsonGetter("mode")
908        public Mode mode() {
909            return mode;
910        }
911
912        /**
913         * Sets the mode.
914         *
915         * @param mode the resource mode to set
916         */
917        public void setMode(Mode mode) {
918            this.mode = mode;
919        }
920
921        /**
922         * Gets the resource name.
923         *
924         * @return the string
925         */
926        @JsonGetter("name")
927        public String name() {
928            return name;
929        }
930
931        /**
932         * Sets the name.
933         *
934         * @param name the resource name to set
935         */
936        public void setName(String name) {
937            this.name = name;
938        }
939
940        /**
941         * Gets the assigned vm.
942         *
943         * @return the string
944         */
945        @JsonGetter("assignedVm")
946        public String assignedVm() {
947            return assignedVm;
948        }
949
950        /**
951         * Sets the assigned vm.
952         *
953         * @param name the assigned vm
954         */
955        public void setAssignedVm(String name) {
956            this.assignedVm = name;
957        }
958
959        @Override
960        public int hashCode() {
961            final int prime = 31;
962            int result = super.hashCode();
963            result = prime * result + java.util.Objects.hash(mode, name);
964            return result;
965        }
966
967        @Override
968        public boolean equals(Object obj) {
969            if (this == obj) {
970                return true;
971            }
972            if (!super.equals(obj)) {
973                return false;
974            }
975            if (getClass() != obj.getClass()) {
976                return false;
977            }
978            ResourceModel other = (ResourceModel) obj;
979            return mode == other.mode
980                && java.util.Objects.equals(name, other.name);
981        }
982
983        @Override
984        public String toString() {
985            StringBuilder builder = new StringBuilder(50);
986            builder.append("AccessModel [mode=").append(mode)
987                .append(", name=").append(name).append(']');
988            return builder.toString();
989        }
990    }
991}