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