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}