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