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