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.JsonObject;
027import com.google.gson.JsonPrimitive;
028import freemarker.core.ParseException;
029import freemarker.template.MalformedTemplateNameException;
030import freemarker.template.Template;
031import freemarker.template.TemplateNotFoundException;
032import io.kubernetes.client.util.Strings;
033import java.io.IOException;
034import java.net.Inet4Address;
035import java.net.Inet6Address;
036import java.net.InetAddress;
037import java.net.UnknownHostException;
038import java.time.Duration;
039import java.util.Base64;
040import java.util.Collections;
041import java.util.EnumSet;
042import java.util.HashSet;
043import java.util.List;
044import java.util.Map;
045import java.util.Optional;
046import java.util.ResourceBundle;
047import java.util.Set;
048import java.util.logging.Level;
049import org.bouncycastle.util.Objects;
050import org.jdrupes.json.JsonBeanDecoder;
051import org.jdrupes.json.JsonDecodeException;
052import org.jdrupes.vmoperator.common.K8sDynamicModel;
053import org.jdrupes.vmoperator.common.K8sObserver;
054import org.jdrupes.vmoperator.common.VmDefinitionModel;
055import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission;
056import org.jdrupes.vmoperator.manager.events.ChannelCache;
057import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
058import org.jdrupes.vmoperator.manager.events.ModifyVm;
059import org.jdrupes.vmoperator.manager.events.ResetVm;
060import org.jdrupes.vmoperator.manager.events.VmChannel;
061import org.jdrupes.vmoperator.manager.events.VmDefChanged;
062import org.jdrupes.vmoperator.util.GsonPtr;
063import org.jgrapes.core.Channel;
064import org.jgrapes.core.Components;
065import org.jgrapes.core.Event;
066import org.jgrapes.core.Manager;
067import org.jgrapes.core.annotation.Handler;
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.NotifyConletModel;
087import org.jgrapes.webconsole.base.events.NotifyConletView;
088import org.jgrapes.webconsole.base.events.OpenModalDialog;
089import org.jgrapes.webconsole.base.events.RenderConlet;
090import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
091import org.jgrapes.webconsole.base.events.SetLocale;
092import org.jgrapes.webconsole.base.events.UpdateConletType;
093import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
094
095/**
096 * The Class VmViewer. The component supports the following
097 * configuration properties:
098 * 
099 *   * `displayResource`: a map with the following entries:
100 *       - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`).
101 *         Determines the IP addresses uses in the generated
102 *         connection file.
103 *   * `deleteConnectionFile`: `true` or `false` (default: `true`).
104 *     If `true`, the downloaded connection file will be deleted by
105 *     the remote viewer when opened.
106 *   * `syncPreviewsFor`: a list objects with either property `user` or
107 *     `role` and the associated name (default: `[]`).
108 *     The remote viewer will synchronize the previews for the specified
109 *     users and roles.
110 *
111 */
112@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports",
113    "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" })
114public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> {
115
116    private static final String VM_NAME_PROPERTY = "vmName";
117    private static final String RENDERED
118        = VmViewer.class.getName() + ".rendered";
119    private static final String PENDING
120        = VmViewer.class.getName() + ".pending";
121    private static final Set<RenderMode> MODES = RenderMode.asSet(
122        RenderMode.Preview, RenderMode.Edit);
123    private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
124        RenderMode.Preview, RenderMode.StickyPreview);
125    private final ChannelCache<String, VmChannel,
126            VmDefinitionModel> channelManager = new ChannelCache<>();
127    private static ObjectMapper objectMapper
128        = new ObjectMapper().registerModule(new JavaTimeModule());
129    private Class<?> preferredIpVersion = Inet4Address.class;
130    private final Set<String> syncUsers = new HashSet<>();
131    private final Set<String> syncRoles = new HashSet<>();
132    private boolean deleteConnectionFile = true;
133
134    /**
135     * The periodically generated update event.
136     */
137    public static class Update extends Event<Void> {
138    }
139
140    /**
141     * Creates a new component with its channel set to the given channel.
142     * 
143     * @param componentChannel the channel that the component's handlers listen
144     * on by default and that {@link Manager#fire(Event, Channel...)}
145     * sends the event to
146     */
147    public VmViewer(Channel componentChannel) {
148        super(componentChannel);
149    }
150
151    /**
152     * Configure the component. 
153     * 
154     * @param event the event
155     */
156    @SuppressWarnings("unchecked")
157    @Handler
158    public void onConfigurationUpdate(ConfigurationUpdate event) {
159        event.structured(componentPath()).ifPresent(c -> {
160            try {
161                var dispRes = (Map<String, Object>) c
162                    .getOrDefault("displayResource", Collections.emptyMap());
163                switch ((String) dispRes.getOrDefault("preferredIpVersion",
164                    "")) {
165                case "ipv6":
166                    preferredIpVersion = Inet6Address.class;
167                    break;
168                case "ipv4":
169                default:
170                    preferredIpVersion = Inet4Address.class;
171                    break;
172                }
173
174                // Delete connection file
175                deleteConnectionFile
176                    = Optional.ofNullable(c.get("deleteConnectionFile"))
177                        .filter(v -> v instanceof String).map(v -> (String) v)
178                        .map(Boolean::parseBoolean).orElse(true);
179
180                // Sync
181                for (var entry : (List<Map<String, String>>) c.getOrDefault(
182                    "syncPreviewsFor", Collections.emptyList())) {
183                    if (entry.containsKey("user")) {
184                        syncUsers.add(entry.get("user"));
185                    } else if (entry.containsKey("role")) {
186                        syncRoles.add(entry.get("role"));
187                    }
188                }
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                && !channelManager.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                && channelManager.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(
375                            model.isGenerated() ? MODES_FOR_GENERATED : MODES));
376            renderedAs.add(RenderMode.Preview);
377            if (!Strings.isNullOrEmpty(model.vmName())) {
378                Optional.ofNullable(channel.session().get(RENDERED))
379                    .ifPresent(s -> ((Set<String>) s).add(model.vmName()));
380                updateConfig(channel, model);
381            }
382        }
383        if (event.renderAs().contains(RenderMode.Edit)) {
384            Template tpl = freemarkerConfig()
385                .getTemplate("VmViewer-edit.ftl.html");
386            var fmModel = fmModel(event, channel, conletId, model);
387            fmModel.put("vmNames", accessibleVms(channel));
388            channel.respond(new OpenModalDialog(type(), conletId,
389                processTemplate(event, tpl, fmModel))
390                    .addOption("cancelable", true)
391                    .addOption("okayLabel",
392                        resourceBundle.getString("okayLabel")));
393        }
394        return renderedAs;
395    }
396
397    private List<String> accessibleVms(ConsoleConnection channel) {
398        return channelManager.associated().stream()
399            .filter(d -> !permissions(d, channel.session()).isEmpty())
400            .map(d -> d.getMetadata().getName()).sorted().toList();
401    }
402
403    private Set<Permission> permissions(VmDefinitionModel vmDef,
404            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        channelManager.associated(model.vmName()).ifPresent(vmDef -> {
423            try {
424                var def = JsonBeanDecoder.create(vmDef.data().toString())
425                    .readObject();
426                def.setField("userPermissions",
427                    permissions(vmDef, channel.session()).stream()
428                        .map(Permission::toString).toList());
429                channel.respond(new NotifyConletView(type(),
430                    model.getConletId(), "updateVmDefinition", def));
431            } catch (JsonDecodeException e) {
432                logger.log(Level.SEVERE, e,
433                    () -> "Failed to serialize VM definition");
434            }
435        });
436    }
437
438    @Override
439    protected void doConletDeleted(ConletDeleted event,
440            ConsoleConnection channel, String conletId, ViewerModel conletState)
441            throws Exception {
442        if (event.renderModes().isEmpty()) {
443            channel.respond(new KeyValueStoreUpdate().delete(
444                storagePath(channel.session(), conletId)));
445        }
446    }
447
448    /**
449     * Track the VM definitions.
450     *
451     * @param event the event
452     * @param channel the channel
453     * @throws JsonDecodeException the json decode exception
454     * @throws IOException 
455     */
456    @Handler(namedChannels = "manager")
457    @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
458        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
459        "PMD.ConfusingArgumentToVarargsMethod" })
460    public void onVmDefChanged(VmDefChanged event, VmChannel channel)
461            throws JsonDecodeException, IOException {
462        var vmDef = new VmDefinitionModel(channel.client().getJSON()
463            .getGson(), event.vmDefinition().data());
464        GsonPtr.to(vmDef.data()).to("metadata").get(JsonObject.class)
465            .remove("managedFields");
466        var vmName = vmDef.getMetadata().getName();
467        if (event.type() == K8sObserver.ResponseType.DELETED) {
468            channelManager.remove(vmName);
469        } else {
470            channelManager.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 -> channelManager.both(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 pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef),
538                    e -> openConsole(vmName, channel, model,
539                        e.password().orElse(null)));
540                fire(pwQuery, vmChannel);
541            }
542            break;
543        default:// ignore
544            break;
545        }
546    }
547
548    private void selectVm(NotifyConletModel event, ConsoleConnection channel,
549            ViewerModel model) throws JsonProcessingException {
550        model.setVmName(event.params().asString(0));
551        String jsonState = objectMapper.writeValueAsString(model);
552        channel.respond(new KeyValueStoreUpdate().update(storagePath(
553            channel.session(), model.getConletId()), jsonState));
554        updateConfig(channel, model);
555    }
556
557    private void openConsole(String vmName, ConsoleConnection connection,
558            ViewerModel model, String password) {
559        var vmDef = channelManager.associated(vmName).orElse(null);
560        if (vmDef == null) {
561            return;
562        }
563        var addr = displayIp(vmDef);
564        if (addr.isEmpty()) {
565            logger.severe(() -> "Failed to find display IP for " + vmName);
566            return;
567        }
568        var port = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec",
569            "vm", "display", "spice", "port");
570        if (port.isEmpty()) {
571            logger.severe(() -> "No port defined for display of " + vmName);
572            return;
573        }
574        var proxyUrl = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec",
575            "vm", "display", "spice", "proxyUrl");
576        StringBuffer data = new StringBuffer(100)
577            .append("[virt-viewer]\ntype=spice\nhost=")
578            .append(addr.get().getHostAddress()).append("\nport=")
579            .append(Integer.toString(port.get().getAsInt()))
580            .append('\n');
581        if (password != null) {
582            data.append("password=").append(password).append('\n');
583        }
584        proxyUrl.map(JsonPrimitive::getAsString).ifPresent(u -> {
585            if (!Strings.isNullOrEmpty(u)) {
586                data.append("proxy=").append(u).append('\n');
587            }
588        });
589        if (deleteConnectionFile) {
590            data.append("delete-this-file=1\n");
591        }
592        connection.respond(new NotifyConletView(type(),
593            model.getConletId(), "openConsole", "application/x-virt-viewer",
594            Base64.getEncoder().encodeToString(data.toString().getBytes())));
595    }
596
597    private Optional<InetAddress> displayIp(K8sDynamicModel vmDef) {
598        var server = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec",
599            "vm", "display", "spice", "server");
600        if (server.isPresent()) {
601            var srv = server.get().getAsString();
602            try {
603                var addr = InetAddress.getByName(srv);
604                logger.fine(() -> "Using IP address from CRD for "
605                    + vmDef.getMetadata().getName() + ": " + addr);
606                return Optional.of(addr);
607            } catch (UnknownHostException e) {
608                logger.log(Level.SEVERE, e, () -> "Invalid server address "
609                    + srv + ": " + e.getMessage());
610                return Optional.empty();
611            }
612        }
613        var addrs = GsonPtr.to(vmDef.data()).getAsListOf(JsonPrimitive.class,
614            "nodeAddresses").stream().map(JsonPrimitive::getAsString)
615            .map(a -> {
616                try {
617                    return InetAddress.getByName(a);
618                } catch (UnknownHostException e) {
619                    logger.warning(() -> "Invalid IP address: " + a);
620                    return null;
621                }
622            }).filter(a -> a != null).toList();
623        logger.fine(() -> "Known IP addresses for "
624            + vmDef.getMetadata().getName() + ": " + addrs);
625        return addrs.stream()
626            .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
627            .findFirst().or(() -> addrs.stream().findFirst());
628    }
629
630    private void confirmReset(NotifyConletModel event,
631            ConsoleConnection channel, ViewerModel model,
632            ResourceBundle resourceBundle) throws TemplateNotFoundException,
633            MalformedTemplateNameException, ParseException, IOException {
634        Template tpl = freemarkerConfig()
635            .getTemplate("VmViewer-confirmReset.ftl.html");
636        channel.respond(new OpenModalDialog(type(), model.getConletId(),
637            processTemplate(event, tpl,
638                fmModel(event, channel, model.getConletId(), model)))
639                    .addOption("cancelable", true).addOption("closeLabel", "")
640                    .addOption("title",
641                        resourceBundle.getString("confirmResetTitle")));
642    }
643
644    @Override
645    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
646            String conletId) throws Exception {
647        return true;
648    }
649
650    /**
651     * The Class VmsModel.
652     */
653    @SuppressWarnings("PMD.DataClass")
654    public static class ViewerModel extends ConletBaseModel {
655
656        private String vmName;
657        private boolean generated;
658
659        /**
660         * Instantiates a new vms model.
661         *
662         * @param conletId the conlet id
663         */
664        public ViewerModel(@JsonProperty("conletId") String conletId) {
665            super(conletId);
666        }
667
668        /**
669         * Gets the vm name.
670         *
671         * @return the vmName
672         */
673        @JsonGetter("vmName")
674        public String vmName() {
675            return vmName;
676        }
677
678        /**
679         * Sets the vm name.
680         *
681         * @param vmName the vmName to set
682         */
683        public void setVmName(String vmName) {
684            this.vmName = vmName;
685        }
686
687        /**
688         * Checks if is generated.
689         *
690         * @return the generated
691         */
692        public boolean isGenerated() {
693            return generated;
694        }
695
696        /**
697         * Sets the generated.
698         *
699         * @param generated the generated to set
700         */
701        public void setGenerated(boolean generated) {
702            this.generated = generated;
703        }
704
705    }
706}