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