001/*
002 * VM-Operator
003 * Copyright (C) 2023,2024 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.vmviewer;
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.net.InetAddress;
036import java.net.UnknownHostException;
037import java.time.Duration;
038import java.util.Base64;
039import java.util.Collections;
040import java.util.EnumSet;
041import java.util.HashSet;
042import java.util.List;
043import java.util.Map;
044import java.util.Optional;
045import java.util.ResourceBundle;
046import java.util.Set;
047import java.util.logging.Level;
048import java.util.stream.Collectors;
049import org.bouncycastle.util.Objects;
050import org.jdrupes.vmoperator.common.K8sObserver;
051import org.jdrupes.vmoperator.common.VmDefinition;
052import org.jdrupes.vmoperator.common.VmDefinition.Permission;
053import org.jdrupes.vmoperator.manager.events.ChannelTracker;
054import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
055import org.jdrupes.vmoperator.manager.events.ModifyVm;
056import org.jdrupes.vmoperator.manager.events.ResetVm;
057import org.jdrupes.vmoperator.manager.events.VmChannel;
058import org.jdrupes.vmoperator.manager.events.VmDefChanged;
059import org.jgrapes.core.Channel;
060import org.jgrapes.core.Components;
061import org.jgrapes.core.Event;
062import org.jgrapes.core.Manager;
063import org.jgrapes.core.annotation.Handler;
064import org.jgrapes.http.Session;
065import org.jgrapes.util.events.ConfigurationUpdate;
066import org.jgrapes.util.events.KeyValueStoreQuery;
067import org.jgrapes.util.events.KeyValueStoreUpdate;
068import org.jgrapes.webconsole.base.Conlet.RenderMode;
069import org.jgrapes.webconsole.base.ConletBaseModel;
070import org.jgrapes.webconsole.base.ConsoleConnection;
071import org.jgrapes.webconsole.base.ConsoleRole;
072import org.jgrapes.webconsole.base.ConsoleUser;
073import org.jgrapes.webconsole.base.WebConsoleUtils;
074import org.jgrapes.webconsole.base.events.AddConletRequest;
075import org.jgrapes.webconsole.base.events.AddConletType;
076import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
077import org.jgrapes.webconsole.base.events.ConletDeleted;
078import org.jgrapes.webconsole.base.events.ConsoleConfigured;
079import org.jgrapes.webconsole.base.events.ConsolePrepared;
080import org.jgrapes.webconsole.base.events.ConsoleReady;
081import org.jgrapes.webconsole.base.events.DeleteConlet;
082import org.jgrapes.webconsole.base.events.NotifyConletModel;
083import org.jgrapes.webconsole.base.events.NotifyConletView;
084import org.jgrapes.webconsole.base.events.OpenModalDialog;
085import org.jgrapes.webconsole.base.events.RenderConlet;
086import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
087import org.jgrapes.webconsole.base.events.SetLocale;
088import org.jgrapes.webconsole.base.events.UpdateConletType;
089import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
090
091/**
092 * The Class VmViewer. The component supports the following
093 * configuration properties:
094 * 
095 *   * `displayResource`: a map with the following entries:
096 *       - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`).
097 *         Determines the IP addresses uses in the generated
098 *         connection file.
099 *   * `deleteConnectionFile`: `true` or `false` (default: `true`).
100 *     If `true`, the downloaded connection file will be deleted by
101 *     the remote viewer when opened.
102 *   * `syncPreviewsFor`: a list objects with either property `user` or
103 *     `role` and the associated name (default: `[]`).
104 *     The remote viewer will synchronize the previews for the specified
105 *     users and roles.
106 *
107 */
108@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports",
109    "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" })
110public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
111
112    private static final String VM_NAME_PROPERTY = "vmName";
113    private static final String RENDERED
114        = VmViewer.class.getName() + ".rendered";
115    private static final String PENDING
116        = VmViewer.class.getName() + ".pending";
117    private static final Set<RenderMode> MODES = RenderMode.asSet(
118        RenderMode.Preview, RenderMode.Edit);
119    private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
120        RenderMode.Preview, RenderMode.StickyPreview);
121    private final ChannelTracker<String, VmChannel,
122            VmDefinition> channelTracker = new ChannelTracker<>();
123    private static ObjectMapper objectMapper
124        = new ObjectMapper().registerModule(new JavaTimeModule());
125    private Class<?> preferredIpVersion = Inet4Address.class;
126    private Set<String> syncUsers = Collections.emptySet();
127    private Set<String> syncRoles = Collections.emptySet();
128    private boolean deleteConnectionFile = true;
129
130    /**
131     * The periodically generated update event.
132     */
133    public static class Update extends Event<Void> {
134    }
135
136    /**
137     * Creates a new component with its channel set to the given channel.
138     * 
139     * @param componentChannel the channel that the component's handlers listen
140     * on by default and that {@link Manager#fire(Event, Channel...)}
141     * sends the event to
142     */
143    public VmViewer(Channel componentChannel) {
144        super(componentChannel);
145    }
146
147    /**
148     * Configure the component. 
149     * 
150     * @param event the event
151     */
152    @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
153    @Handler
154    public void onConfigurationUpdate(ConfigurationUpdate event) {
155        event.structured(componentPath()).ifPresent(c -> {
156            try {
157                var dispRes = (Map<String, Object>) c
158                    .getOrDefault("displayResource", Collections.emptyMap());
159                switch ((String) dispRes.getOrDefault("preferredIpVersion",
160                    "")) {
161                case "ipv6":
162                    preferredIpVersion = Inet6Address.class;
163                    break;
164                case "ipv4":
165                default:
166                    preferredIpVersion = Inet4Address.class;
167                    break;
168                }
169
170                // Delete connection file
171                deleteConnectionFile
172                    = Optional.ofNullable(c.get("deleteConnectionFile"))
173                        .filter(v -> v instanceof String).map(v -> (String) v)
174                        .map(Boolean::parseBoolean).orElse(true);
175
176                // Users or roles for which previews should be synchronized
177                syncUsers = ((List<Map<String, String>>) c.getOrDefault(
178                    "syncPreviewsFor", Collections.emptyList())).stream()
179                        .map(m -> m.get("user"))
180                        .filter(s -> s != null).collect(Collectors.toSet());
181                logger.finest(() -> "Syncing previews for users: "
182                    + syncUsers.toString());
183                syncRoles = ((List<Map<String, String>>) c.getOrDefault(
184                    "syncPreviewsFor", Collections.emptyList())).stream()
185                        .map(m -> m.get("role"))
186                        .filter(s -> s != null).collect(Collectors.toSet());
187                logger.finest(() -> "Syncing previews for roles: "
188                    + syncRoles.toString());
189            } catch (ClassCastException e) {
190                logger.config("Malformed configuration: " + e.getMessage());
191            }
192        });
193    }
194
195    private boolean syncPreviews(Session session) {
196        return WebConsoleUtils.userFromSession(session)
197            .filter(u -> syncUsers.contains(u.getName())).isPresent()
198            || WebConsoleUtils.rolesFromSession(session).stream()
199                .filter(cr -> syncRoles.contains(cr.getName())).findAny()
200                .isPresent();
201    }
202
203    /**
204     * On {@link ConsoleReady}, fire the {@link AddConletType}.
205     *
206     * @param event the event
207     * @param channel the channel
208     * @throws TemplateNotFoundException the template not found exception
209     * @throws MalformedTemplateNameException the malformed template name
210     *             exception
211     * @throws ParseException the parse exception
212     * @throws IOException Signals that an I/O exception has occurred.
213     */
214    @Handler
215    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
216            throws TemplateNotFoundException, MalformedTemplateNameException,
217            ParseException, IOException {
218        // Add conlet resources to page
219        channel.respond(new AddConletType(type())
220            .setDisplayNames(
221                localizations(channel.supportedLocales(), "conletName"))
222            .addRenderMode(RenderMode.Preview)
223            .addScript(new ScriptResource().setScriptType("module")
224                .setScriptUri(event.renderSupport().conletResource(
225                    type(), "VmViewer-functions.js"))));
226        channel.session().put(RENDERED, new HashSet<>());
227    }
228
229    /**
230     * On console configured.
231     *
232     * @param event the event
233     * @param connection the console connection
234     * @throws InterruptedException the interrupted exception
235     */
236    @Handler
237    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
238    public void onConsoleConfigured(ConsoleConfigured event,
239            ConsoleConnection connection) throws InterruptedException,
240            IOException {
241        @SuppressWarnings("unchecked")
242        final var rendered = (Set<String>) connection.session().get(RENDERED);
243        connection.session().remove(RENDERED);
244        if (!syncPreviews(connection.session())) {
245            return;
246        }
247
248        boolean foundMissing = false;
249        for (var vmName : accessibleVms(connection)) {
250            if (rendered.contains(vmName)) {
251                continue;
252            }
253            if (!foundMissing) {
254                // Suspending to allow rendering of conlets to be noticed
255                var failSafe = Components.schedule(t -> event.resumeHandling(),
256                    Duration.ofSeconds(1));
257                event.suspendHandling(failSafe::cancel);
258                connection.setAssociated(PENDING, event);
259                foundMissing = true;
260            }
261            fire(new AddConletRequest(event.event().event().renderSupport(),
262                VmViewer.class.getName(),
263                RenderMode.asSet(RenderMode.Preview))
264                    .addProperty(VM_NAME_PROPERTY, vmName),
265                connection);
266        }
267    }
268
269    /**
270     * On console prepared.
271     *
272     * @param event the event
273     * @param connection the connection
274     */
275    @Handler
276    public void onConsolePrepared(ConsolePrepared event,
277            ConsoleConnection connection) {
278        if (syncPreviews(connection.session())) {
279            connection.respond(new UpdateConletType(type()));
280        }
281    }
282
283    private String storagePath(Session session, String conletId) {
284        return "/" + WebConsoleUtils.userFromSession(session)
285            .map(ConsoleUser::getName).orElse("")
286            + "/" + VmViewer.class.getName() + "/" + conletId;
287    }
288
289    @Override
290    protected Optional<ViewerModel> createNewState(AddConletRequest event,
291            ConsoleConnection connection, String conletId) throws Exception {
292        var model = new ViewerModel(conletId);
293        model.vmName = (String) event.properties().get(VM_NAME_PROPERTY);
294        if (model.vmName != null) {
295            model.setGenerated(true);
296        }
297        String jsonState = objectMapper.writeValueAsString(model);
298        connection.respond(new KeyValueStoreUpdate().update(
299            storagePath(connection.session(), model.getConletId()), jsonState));
300        return Optional.of(model);
301    }
302
303    @Override
304    protected Optional<ViewerModel> createStateRepresentation(Event<?> event,
305            ConsoleConnection connection, String conletId) throws Exception {
306        var model = new ViewerModel(conletId);
307        String jsonState = objectMapper.writeValueAsString(model);
308        connection.respond(new KeyValueStoreUpdate().update(
309            storagePath(connection.session(), model.getConletId()), jsonState));
310        return Optional.of(model);
311    }
312
313    @Override
314    @SuppressWarnings("PMD.EmptyCatchBlock")
315    protected Optional<ViewerModel> recreateState(Event<?> event,
316            ConsoleConnection channel, String conletId) throws Exception {
317        KeyValueStoreQuery query = new KeyValueStoreQuery(
318            storagePath(channel.session(), conletId), channel);
319        newEventPipeline().fire(query, channel);
320        try {
321            if (!query.results().isEmpty()) {
322                var json = query.results().get(0).values().stream().findFirst()
323                    .get();
324                ViewerModel model
325                    = objectMapper.readValue(json, ViewerModel.class);
326                return Optional.of(model);
327            }
328        } catch (InterruptedException e) {
329            // Means we have no result.
330        }
331
332        // Fall back to creating default state.
333        return createStateRepresentation(event, channel, conletId);
334    }
335
336    @Override
337    @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" })
338    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
339            ConsoleConnection channel, String conletId, ViewerModel model)
340            throws Exception {
341        ResourceBundle resourceBundle = resourceBundle(channel.locale());
342        Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class);
343        if (event.renderAs().contains(RenderMode.Preview)) {
344            channel.associated(PENDING, Event.class)
345                .ifPresent(e -> {
346                    e.resumeHandling();
347                    channel.setAssociated(PENDING, null);
348                });
349
350            // Remove conlet if definition has been removed
351            if (model.vmName() != null
352                && !channelTracker.associated(model.vmName()).isPresent()) {
353                channel.respond(
354                    new DeleteConlet(conletId, Collections.emptySet()));
355                return Collections.emptySet();
356            }
357
358            // Don't render if user has not at least one permission
359            if (model.vmName() != null
360                && channelTracker.associated(model.vmName())
361                    .map(d -> permissions(d, channel.session()).isEmpty())
362                    .orElse(true)) {
363                return Collections.emptySet();
364            }
365
366            // Render
367            Template tpl
368                = freemarkerConfig().getTemplate("VmViewer-preview.ftl.html");
369            channel.respond(new RenderConlet(type(), conletId,
370                processTemplate(event, tpl,
371                    fmModel(event, channel, conletId, model)))
372                        .setRenderAs(
373                            RenderMode.Preview.addModifiers(event.renderAs()))
374                        .setSupportedModes(syncPreviews(channel.session())
375                            ? MODES_FOR_GENERATED
376                            : MODES));
377            renderedAs.add(RenderMode.Preview);
378            if (!Strings.isNullOrEmpty(model.vmName())) {
379                Optional.ofNullable(channel.session().get(RENDERED))
380                    .ifPresent(s -> ((Set<String>) s).add(model.vmName()));
381                updateConfig(channel, model);
382            }
383        }
384        if (event.renderAs().contains(RenderMode.Edit)) {
385            Template tpl = freemarkerConfig()
386                .getTemplate("VmViewer-edit.ftl.html");
387            var fmModel = fmModel(event, channel, conletId, model);
388            fmModel.put("vmNames", accessibleVms(channel));
389            channel.respond(new OpenModalDialog(type(), conletId,
390                processTemplate(event, tpl, fmModel))
391                    .addOption("cancelable", true)
392                    .addOption("okayLabel",
393                        resourceBundle.getString("okayLabel")));
394        }
395        return renderedAs;
396    }
397
398    private List<String> accessibleVms(ConsoleConnection channel) {
399        return channelTracker.associated().stream()
400            .filter(d -> !permissions(d, channel.session()).isEmpty())
401            .map(d -> d.getMetadata().getName()).sorted().toList();
402    }
403
404    private Set<Permission> permissions(VmDefinition vmDef, Session session) {
405        var user = WebConsoleUtils.userFromSession(session)
406            .map(ConsoleUser::getName).orElse(null);
407        var roles = WebConsoleUtils.rolesFromSession(session)
408            .stream().map(ConsoleRole::getName).toList();
409        return vmDef.permissionsFor(user, roles);
410    }
411
412    private void updateConfig(ConsoleConnection channel, ViewerModel model) {
413        channel.respond(new NotifyConletView(type(),
414            model.getConletId(), "updateConfig", model.vmName()));
415        updateVmDef(channel, model);
416    }
417
418    private void updateVmDef(ConsoleConnection channel, ViewerModel model) {
419        if (Strings.isNullOrEmpty(model.vmName())) {
420            return;
421        }
422        channelTracker.value(model.vmName()).ifPresent(item -> {
423            try {
424                var vmDef = item.associated();
425                var data = Map.of("metadata",
426                    Map.of("namespace", vmDef.namespace(),
427                        "name", vmDef.name()),
428                    "spec", vmDef.spec(),
429                    "status", vmDef.getStatus(),
430                    "userPermissions",
431                    permissions(vmDef, channel.session()).stream()
432                        .map(Permission::toString).toList());
433                channel.respond(new NotifyConletView(type(),
434                    model.getConletId(), "updateVmDefinition", data));
435            } catch (JsonSyntaxException e) {
436                logger.log(Level.SEVERE, e,
437                    () -> "Failed to serialize VM definition");
438            }
439        });
440    }
441
442    @Override
443    protected void doConletDeleted(ConletDeleted event,
444            ConsoleConnection channel, String conletId, ViewerModel conletState)
445            throws Exception {
446        if (event.renderModes().isEmpty()) {
447            channel.respond(new KeyValueStoreUpdate().delete(
448                storagePath(channel.session(), conletId)));
449        }
450    }
451
452    /**
453     * Track the VM definitions.
454     *
455     * @param event the event
456     * @param channel the channel
457     * @throws IOException 
458     */
459    @Handler(namedChannels = "manager")
460    @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
461        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
462        "PMD.ConfusingArgumentToVarargsMethod" })
463    public void onVmDefChanged(VmDefChanged event, VmChannel channel)
464            throws IOException {
465        var vmDef = event.vmDefinition();
466        var vmName = vmDef.name();
467        if (event.type() == K8sObserver.ResponseType.DELETED) {
468            channelTracker.remove(vmName);
469        } else {
470            channelTracker.put(vmName, channel, vmDef);
471        }
472        for (var entry : conletIdsByConsoleConnection().entrySet()) {
473            var connection = entry.getKey();
474            for (var conletId : entry.getValue()) {
475                var model = stateFromSession(connection.session(), conletId);
476                if (model.isEmpty()
477                    || !Objects.areEqual(model.get().vmName(), vmName)) {
478                    continue;
479                }
480                if (event.type() == K8sObserver.ResponseType.DELETED) {
481                    connection.respond(
482                        new DeleteConlet(conletId, Collections.emptySet()));
483                } else {
484                    updateVmDef(connection, model.get());
485                }
486            }
487        }
488    }
489
490    @Override
491    @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor",
492        "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount",
493        "PMD.AvoidLiteralsInIfCondition" })
494    protected void doUpdateConletState(NotifyConletModel event,
495            ConsoleConnection channel, ViewerModel model)
496            throws Exception {
497        event.stop();
498        if ("selectedVm".equals(event.method())) {
499            selectVm(event, channel, model);
500            return;
501        }
502
503        // Handle command for selected VM
504        var both = Optional.ofNullable(model.vmName())
505            .flatMap(vm -> channelTracker.value(vm));
506        if (both.isEmpty()) {
507            return;
508        }
509        var vmChannel = both.get().channel();
510        var vmDef = both.get().associated();
511        var vmName = vmDef.metadata().getName();
512        var perms = permissions(vmDef, channel.session());
513        var resourceBundle = resourceBundle(channel.locale());
514        switch (event.method()) {
515        case "start":
516            if (perms.contains(Permission.START)) {
517                fire(new ModifyVm(vmName, "state", "Running", vmChannel));
518            }
519            break;
520        case "stop":
521            if (perms.contains(Permission.STOP)) {
522                fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
523            }
524            break;
525        case "reset":
526            if (perms.contains(Permission.RESET)) {
527                confirmReset(event, channel, model, resourceBundle);
528            }
529            break;
530        case "resetConfirmed":
531            if (perms.contains(Permission.RESET)) {
532                fire(new ResetVm(vmName), vmChannel);
533            }
534            break;
535        case "openConsole":
536            if (perms.contains(Permission.ACCESS_CONSOLE)) {
537                var user = WebConsoleUtils.userFromSession(channel.session())
538                    .map(ConsoleUser::getName).orElse("");
539                var pwQuery
540                    = Event.onCompletion(new GetDisplayPassword(vmDef, user),
541                        e -> openConsole(vmName, channel, model,
542                            e.password().orElse(null)));
543                fire(pwQuery, vmChannel);
544            }
545            break;
546        default:// ignore
547            break;
548        }
549    }
550
551    private void selectVm(NotifyConletModel event, ConsoleConnection channel,
552            ViewerModel model) throws JsonProcessingException {
553        model.setVmName(event.param(0));
554        String jsonState = objectMapper.writeValueAsString(model);
555        channel.respond(new KeyValueStoreUpdate().update(storagePath(
556            channel.session(), model.getConletId()), jsonState));
557        updateConfig(channel, model);
558    }
559
560    private void openConsole(String vmName, ConsoleConnection connection,
561            ViewerModel model, String password) {
562        var vmDef = channelTracker.associated(vmName).orElse(null);
563        if (vmDef == null) {
564            return;
565        }
566        var addr = displayIp(vmDef);
567        if (addr.isEmpty()) {
568            logger.severe(() -> "Failed to find display IP for " + vmName);
569            return;
570        }
571        var port = vmDef.<Number> fromVm("display", "spice", "port")
572            .map(Number::longValue);
573        if (port.isEmpty()) {
574            logger.severe(() -> "No port defined for display of " + vmName);
575            return;
576        }
577        StringBuffer data = new StringBuffer(100)
578            .append("[virt-viewer]\ntype=spice\nhost=")
579            .append(addr.get().getHostAddress()).append("\nport=")
580            .append(port.get().toString())
581            .append('\n');
582        if (password != null) {
583            data.append("password=").append(password).append('\n');
584        }
585        vmDef.<String> fromVm("display", "spice", "proxyUrl")
586            .ifPresent(u -> {
587                if (!Strings.isNullOrEmpty(u)) {
588                    data.append("proxy=").append(u).append('\n');
589                }
590            });
591        if (deleteConnectionFile) {
592            data.append("delete-this-file=1\n");
593        }
594        connection.respond(new NotifyConletView(type(),
595            model.getConletId(), "openConsole", "application/x-virt-viewer",
596            Base64.getEncoder().encodeToString(data.toString().getBytes())));
597    }
598
599    private Optional<InetAddress> displayIp(VmDefinition vmDef) {
600        Optional<String> server = vmDef.fromVm("display", "spice", "server");
601        if (server.isPresent()) {
602            var srv = server.get();
603            try {
604                var addr = InetAddress.getByName(srv);
605                logger.fine(() -> "Using IP address from CRD for "
606                    + vmDef.getMetadata().getName() + ": " + addr);
607                return Optional.of(addr);
608            } catch (UnknownHostException e) {
609                logger.log(Level.SEVERE, e, () -> "Invalid server address "
610                    + srv + ": " + e.getMessage());
611                return Optional.empty();
612            }
613        }
614        var addrs = Optional.<List<String>> ofNullable(vmDef
615            .extra("nodeAddresses")).orElse(Collections.emptyList()).stream()
616            .map(a -> {
617                try {
618                    return InetAddress.getByName(a);
619                } catch (UnknownHostException e) {
620                    logger.warning(() -> "Invalid IP address: " + a);
621                    return null;
622                }
623            }).filter(a -> a != null).toList();
624        logger.fine(() -> "Known IP addresses for "
625            + vmDef.name() + ": " + addrs);
626        return addrs.stream()
627            .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
628            .findFirst().or(() -> addrs.stream().findFirst());
629    }
630
631    private void confirmReset(NotifyConletModel event,
632            ConsoleConnection channel, ViewerModel model,
633            ResourceBundle resourceBundle) throws TemplateNotFoundException,
634            MalformedTemplateNameException, ParseException, IOException {
635        Template tpl = freemarkerConfig()
636            .getTemplate("VmViewer-confirmReset.ftl.html");
637        channel.respond(new OpenModalDialog(type(), model.getConletId(),
638            processTemplate(event, tpl,
639                fmModel(event, channel, model.getConletId(), model)))
640                    .addOption("cancelable", true).addOption("closeLabel", "")
641                    .addOption("title",
642                        resourceBundle.getString("confirmResetTitle")));
643    }
644
645    @Override
646    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
647            String conletId) throws Exception {
648        return true;
649    }
650
651    /**
652     * The Class VmsModel.
653     */
654    @SuppressWarnings("PMD.DataClass")
655    public static class ViewerModel extends ConletBaseModel {
656
657        private String vmName;
658        private boolean generated;
659
660        /**
661         * Instantiates a new vms model.
662         *
663         * @param conletId the conlet id
664         */
665        public ViewerModel(@JsonProperty("conletId") String conletId) {
666            super(conletId);
667        }
668
669        /**
670         * Gets the vm name.
671         *
672         * @return the vmName
673         */
674        @JsonGetter("vmName")
675        public String vmName() {
676            return vmName;
677        }
678
679        /**
680         * Sets the vm name.
681         *
682         * @param vmName the vmName to set
683         */
684        public void setVmName(String vmName) {
685            this.vmName = vmName;
686        }
687
688        /**
689         * Checks if is generated.
690         *
691         * @return the generated
692         */
693        public boolean isGenerated() {
694            return generated;
695        }
696
697        /**
698         * Sets the generated.
699         *
700         * @param generated the generated to set
701         */
702        public void setGenerated(boolean generated) {
703            this.generated = generated;
704        }
705
706    }
707}