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.Permission; 050import org.jdrupes.vmoperator.common.VmPool; 051import org.jdrupes.vmoperator.manager.events.AssignVm; 052import org.jdrupes.vmoperator.manager.events.GetPools; 053import org.jdrupes.vmoperator.manager.events.GetVms; 054import org.jdrupes.vmoperator.manager.events.GetVms.VmData; 055import org.jdrupes.vmoperator.manager.events.ModifyVm; 056import org.jdrupes.vmoperator.manager.events.PrepareConsole; 057import org.jdrupes.vmoperator.manager.events.ResetVm; 058import org.jdrupes.vmoperator.manager.events.VmChannel; 059import org.jdrupes.vmoperator.manager.events.VmDefChanged; 060import org.jdrupes.vmoperator.manager.events.VmPoolChanged; 061import org.jgrapes.core.Channel; 062import org.jgrapes.core.Components; 063import org.jgrapes.core.Event; 064import org.jgrapes.core.EventPipeline; 065import org.jgrapes.core.Manager; 066import org.jgrapes.core.annotation.Handler; 067import org.jgrapes.core.events.Start; 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.DisplayNotification; 087import org.jgrapes.webconsole.base.events.NotifyConletModel; 088import org.jgrapes.webconsole.base.events.NotifyConletView; 089import org.jgrapes.webconsole.base.events.OpenModalDialog; 090import org.jgrapes.webconsole.base.events.RenderConlet; 091import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 092import org.jgrapes.webconsole.base.events.SetLocale; 093import org.jgrapes.webconsole.base.events.UpdateConletType; 094import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 095 096/** 097 * The Class {@link VmAccess}. The component supports the following 098 * configuration properties: 099 * 100 * * `displayResource`: a map with the following entries: 101 * - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`). 102 * Determines the IP addresses uses in the generated 103 * connection file. 104 * * `deleteConnectionFile`: `true` or `false` (default: `true`). 105 * If `true`, the downloaded connection file will be deleted by 106 * the remote viewer when opened. 107 * * `syncPreviewsFor`: a list objects with either property `user` or 108 * `role` and the associated name (default: `[]`). 109 * The remote viewer will synchronize the previews for the specified 110 * users and roles. 111 * 112 */ 113@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", 114 "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods", 115 "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 private Class<?> preferredIpVersion = Inet4Address.class; 132 private Set<String> syncUsers = Collections.emptySet(); 133 private Set<String> syncRoles = Collections.emptySet(); 134 private boolean deleteConnectionFile = true; 135 136 /** 137 * The periodically generated update event. 138 */ 139 public static class Update extends Event<Void> { 140 } 141 142 /** 143 * Creates a new component with its channel set to the given channel. 144 * 145 * @param componentChannel the channel that the component's handlers listen 146 * on by default and that {@link Manager#fire(Event, Channel...)} 147 * sends the event to 148 */ 149 public VmAccess(Channel componentChannel) { 150 super(componentChannel); 151 } 152 153 /** 154 * On start. 155 * 156 * @param event the event 157 */ 158 @Handler 159 public void onStart(Start event) { 160 appPipeline = event.processedBy().get(); 161 } 162 163 /** 164 * Configure the component. 165 * 166 * @param event the event 167 */ 168 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 169 @Handler 170 public void onConfigurationUpdate(ConfigurationUpdate event) { 171 event.structured(componentPath()) 172 .or(() -> { 173 var oldConfig = event.structured("/Manager/GuiHttpServer" 174 + "/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"); 175 if (oldConfig.isPresent()) { 176 logger.warning(() -> "Using configuration with old " 177 + "component name \"VmViewer\", please update to " 178 + "\"VmAccess\""); 179 } 180 return oldConfig; 181 }) 182 .ifPresent(c -> { 183 try { 184 var dispRes = (Map<String, Object>) c 185 .getOrDefault("displayResource", 186 Collections.emptyMap()); 187 switch ((String) dispRes.getOrDefault("preferredIpVersion", 188 "")) { 189 case "ipv6": 190 preferredIpVersion = Inet6Address.class; 191 break; 192 case "ipv4": 193 default: 194 preferredIpVersion = Inet4Address.class; 195 break; 196 } 197 198 // Delete connection file 199 deleteConnectionFile 200 = Optional.ofNullable(c.get("deleteConnectionFile")) 201 .map(Object::toString).map(Boolean::parseBoolean) 202 .orElse(true); 203 204 // Users or roles for which previews should be synchronized 205 syncUsers = ((List<Map<String, String>>) c.getOrDefault( 206 "syncPreviewsFor", Collections.emptyList())).stream() 207 .map(m -> m.get("user")) 208 .filter(s -> s != null).collect(Collectors.toSet()); 209 logger.finest(() -> "Syncing previews for users: " 210 + syncUsers.toString()); 211 syncRoles = ((List<Map<String, String>>) c.getOrDefault( 212 "syncPreviewsFor", Collections.emptyList())).stream() 213 .map(m -> m.get("role")) 214 .filter(s -> s != null).collect(Collectors.toSet()); 215 logger.finest(() -> "Syncing previews for roles: " 216 + syncRoles.toString()); 217 } catch (ClassCastException e) { 218 logger.config("Malformed configuration: " + e.getMessage()); 219 } 220 }); 221 } 222 223 private boolean syncPreviews(Session session) { 224 return WebConsoleUtils.userFromSession(session) 225 .filter(u -> syncUsers.contains(u.getName())).isPresent() 226 || WebConsoleUtils.rolesFromSession(session).stream() 227 .filter(cr -> syncRoles.contains(cr.getName())).findAny() 228 .isPresent(); 229 } 230 231 /** 232 * On {@link ConsoleReady}, fire the {@link AddConletType}. 233 * 234 * @param event the event 235 * @param channel the channel 236 * @throws TemplateNotFoundException the template not found exception 237 * @throws MalformedTemplateNameException the malformed template name 238 * exception 239 * @throws ParseException the parse exception 240 * @throws IOException Signals that an I/O exception has occurred. 241 */ 242 @Handler 243 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 244 throws TemplateNotFoundException, MalformedTemplateNameException, 245 ParseException, IOException { 246 // Add conlet resources to page 247 channel.respond(new AddConletType(type()) 248 .setDisplayNames( 249 localizations(channel.supportedLocales(), "conletName")) 250 .addRenderMode(RenderMode.Preview) 251 .addScript(new ScriptResource().setScriptType("module") 252 .setScriptUri(event.renderSupport().conletResource( 253 type(), "VmAccess-functions.js")))); 254 channel.session().put(RENDERED, new HashSet<>()); 255 } 256 257 /** 258 * On console configured. 259 * 260 * @param event the event 261 * @param connection the console connection 262 * @throws InterruptedException the interrupted exception 263 */ 264 @Handler 265 public void onConsoleConfigured(ConsoleConfigured event, 266 ConsoleConnection connection) throws InterruptedException, 267 IOException { 268 @SuppressWarnings("unchecked") 269 final var rendered 270 = (Set<ResourceModel>) connection.session().get(RENDERED); 271 connection.session().remove(RENDERED); 272 if (!syncPreviews(connection.session())) { 273 return; 274 } 275 addMissingConlets(event, connection, rendered); 276 } 277 278 @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", 279 "PMD.AvoidDuplicateLiterals" }) 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 @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) 408 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 409 ConsoleConnection channel, String conletId, ResourceModel model) 410 throws Exception { 411 if (event.renderAs().contains(RenderMode.Preview)) { 412 return renderPreview(event, channel, conletId, model); 413 } 414 415 // Render edit 416 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 417 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 418 if (event.renderAs().contains(RenderMode.Edit)) { 419 var session = channel.session(); 420 var vmNames = appPipeline.fire(new GetVms().accessibleFor( 421 WebConsoleUtils.userFromSession(session) 422 .map(ConsoleUser::getName).orElse(null), 423 WebConsoleUtils.rolesFromSession(session).stream() 424 .map(ConsoleRole::getName).toList())) 425 .get().stream().map(d -> d.definition().name()).sorted() 426 .toList(); 427 var poolNames = appPipeline.fire(new GetPools().accessibleFor( 428 WebConsoleUtils.userFromSession(session) 429 .map(ConsoleUser::getName).orElse(null), 430 WebConsoleUtils.rolesFromSession(session).stream() 431 .map(ConsoleRole::getName).toList())) 432 .get().stream().map(VmPool::name).sorted().toList(); 433 Template tpl 434 = freemarkerConfig().getTemplate("VmAccess-edit.ftl.html"); 435 var fmModel = fmModel(event, channel, conletId, model); 436 fmModel.put("vmNames", vmNames); 437 fmModel.put("poolNames", poolNames); 438 channel.respond(new OpenModalDialog(type(), conletId, 439 processTemplate(event, tpl, fmModel)) 440 .addOption("cancelable", true) 441 .addOption("okayLabel", 442 resourceBundle.getString("okayLabel"))); 443 } 444 return renderedAs; 445 } 446 447 @SuppressWarnings("unchecked") 448 private Set<RenderMode> renderPreview(RenderConletRequestBase<?> event, 449 ConsoleConnection channel, String conletId, ResourceModel model) 450 throws TemplateNotFoundException, MalformedTemplateNameException, 451 ParseException, IOException, InterruptedException { 452 channel.associated(PENDING, Event.class) 453 .ifPresent(e -> { 454 e.resumeHandling(); 455 channel.setAssociated(PENDING, null); 456 }); 457 458 VmDefinition vmDef = null; 459 if (model.mode() == ResourceModel.Mode.VM && model.name() != null) { 460 // Remove conlet if VM definition has been removed 461 // or user has not at least one permission 462 vmDef = getVmData(model, channel).map(VmData::definition) 463 .orElse(null); 464 if (vmDef == null) { 465 channel.respond( 466 new DeleteConlet(conletId, Collections.emptySet())); 467 return Collections.emptySet(); 468 } 469 } 470 471 if (model.mode() == ResourceModel.Mode.POOL && model.name() != null) { 472 // Remove conlet if pool definition has been removed 473 // or user has not at least one permission 474 VmPool pool = appPipeline 475 .fire(new GetPools().withName(model.name())).get() 476 .stream().findFirst().orElse(null); 477 if (pool == null 478 || permissions(pool, channel.session()).isEmpty()) { 479 channel.respond( 480 new DeleteConlet(conletId, Collections.emptySet())); 481 return Collections.emptySet(); 482 } 483 vmDef = getVmData(model, channel).map(VmData::definition) 484 .orElse(null); 485 } 486 487 // Render 488 Template tpl 489 = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); 490 channel.respond(new RenderConlet(type(), conletId, 491 processTemplate(event, tpl, 492 fmModel(event, channel, conletId, model))) 493 .setRenderAs( 494 RenderMode.Preview.addModifiers(event.renderAs())) 495 .setSupportedModes(syncPreviews(channel.session()) 496 ? MODES_FOR_GENERATED 497 : MODES)); 498 if (!Strings.isNullOrEmpty(model.name())) { 499 Optional.ofNullable(channel.session().get(RENDERED)) 500 .ifPresent(s -> ((Set<ResourceModel>) s).add(model)); 501 updatePreview(channel, model, vmDef); 502 } 503 return EnumSet.of(RenderMode.Preview); 504 } 505 506 private Optional<VmData> getVmData(ResourceModel model, 507 ConsoleConnection channel) throws InterruptedException { 508 if (model.mode() == ResourceModel.Mode.VM) { 509 // Get the VM data by name. 510 var session = channel.session(); 511 return appPipeline.fire(new GetVms().withName(model.name()) 512 .accessibleFor(WebConsoleUtils.userFromSession(session) 513 .map(ConsoleUser::getName).orElse(null), 514 WebConsoleUtils.rolesFromSession(session).stream() 515 .map(ConsoleRole::getName).toList())) 516 .get().stream().findFirst(); 517 } 518 519 // Look for an (already) assigned VM 520 var user = WebConsoleUtils.userFromSession(channel.session()) 521 .map(ConsoleUser::getName).orElse(null); 522 return appPipeline.fire(new GetVms().assignedFrom(model.name()) 523 .assignedTo(user)).get().stream().findFirst(); 524 } 525 526 private Set<Permission> permissions(VmDefinition vmDef, Session session) { 527 var user = WebConsoleUtils.userFromSession(session) 528 .map(ConsoleUser::getName).orElse(null); 529 var roles = WebConsoleUtils.rolesFromSession(session) 530 .stream().map(ConsoleRole::getName).toList(); 531 return vmDef.permissionsFor(user, roles); 532 } 533 534 private Set<Permission> permissions(VmPool pool, Session session) { 535 var user = WebConsoleUtils.userFromSession(session) 536 .map(ConsoleUser::getName).orElse(null); 537 var roles = WebConsoleUtils.rolesFromSession(session) 538 .stream().map(ConsoleRole::getName).toList(); 539 return pool.permissionsFor(user, roles); 540 } 541 542 private Set<Permission> permissions(ResourceModel model, Session session, 543 VmPool pool, VmDefinition vmDef) throws InterruptedException { 544 var user = WebConsoleUtils.userFromSession(session) 545 .map(ConsoleUser::getName).orElse(null); 546 var roles = WebConsoleUtils.rolesFromSession(session) 547 .stream().map(ConsoleRole::getName).toList(); 548 if (model.mode() == ResourceModel.Mode.POOL) { 549 if (pool == null) { 550 pool = appPipeline.fire(new GetPools() 551 .withName(model.name())).get().stream().findFirst() 552 .orElse(null); 553 } 554 if (pool == null) { 555 return Collections.emptySet(); 556 } 557 return pool.permissionsFor(user, roles); 558 } 559 if (vmDef == null) { 560 vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) 561 .assignedTo(user)).get().stream().map(VmData::definition) 562 .findFirst().orElse(null); 563 } 564 if (vmDef == null) { 565 return Collections.emptySet(); 566 } 567 return vmDef.permissionsFor(user, roles); 568 } 569 570 private void updatePreview(ConsoleConnection channel, ResourceModel model, 571 VmDefinition vmDef) throws InterruptedException { 572 updateConfig(channel, model, vmDef); 573 updateVmDef(channel, model, vmDef); 574 } 575 576 private void updateConfig(ConsoleConnection channel, ResourceModel model, 577 VmDefinition vmDef) throws InterruptedException { 578 channel.respond(new NotifyConletView(type(), 579 model.getConletId(), "updateConfig", model.mode(), model.name(), 580 permissions(model, channel.session(), null, vmDef).stream() 581 .map(VmDefinition.Permission::toString).toList())); 582 } 583 584 private void updateVmDef(ConsoleConnection channel, ResourceModel model, 585 VmDefinition vmDef) throws InterruptedException { 586 Map<String, Object> data = null; 587 if (vmDef == null) { 588 model.setAssignedVm(null); 589 } else { 590 model.setAssignedVm(vmDef.name()); 591 try { 592 data = Map.of("metadata", 593 Map.of("namespace", vmDef.namespace(), 594 "name", vmDef.name()), 595 "spec", vmDef.spec(), 596 "status", vmDef.status()); 597 } catch (JsonSyntaxException e) { 598 logger.log(Level.SEVERE, e, 599 () -> "Failed to serialize VM definition"); 600 return; 601 } 602 } 603 channel.respond(new NotifyConletView(type(), 604 model.getConletId(), "updateVmDefinition", data)); 605 } 606 607 @Override 608 protected void doConletDeleted(ConletDeleted event, 609 ConsoleConnection channel, String conletId, 610 ResourceModel conletState) 611 throws Exception { 612 if (event.renderModes().isEmpty()) { 613 channel.respond(new KeyValueStoreUpdate().delete( 614 storagePath(channel.session(), conletId))); 615 } 616 } 617 618 /** 619 * Track the VM definitions and update conlets. 620 * 621 * @param event the event 622 * @param channel the channel 623 * @throws IOException 624 * @throws InterruptedException 625 */ 626 @Handler(namedChannels = "manager") 627 @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", 628 "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", 629 "PMD.ConfusingArgumentToVarargsMethod" }) 630 public void onVmDefChanged(VmDefChanged event, VmChannel channel) 631 throws IOException, InterruptedException { 632 var vmDef = event.vmDefinition(); 633 634 // Update known conlets 635 for (var entry : conletIdsByConsoleConnection().entrySet()) { 636 var connection = entry.getKey(); 637 for (var conletId : entry.getValue()) { 638 var model = stateFromSession(connection.session(), conletId); 639 if (model.isEmpty() 640 || Strings.isNullOrEmpty(model.get().name())) { 641 continue; 642 } 643 if (model.get().mode() == ResourceModel.Mode.VM) { 644 // Check if this VM is used by conlet 645 if (!Objects.areEqual(model.get().name(), vmDef.name())) { 646 continue; 647 } 648 if (event.type() == K8sObserver.ResponseType.DELETED 649 || permissions(vmDef, connection.session()).isEmpty()) { 650 connection.respond( 651 new DeleteConlet(conletId, Collections.emptySet())); 652 continue; 653 } 654 } else { 655 // Check if VM is used by pool conlet or to be assigned to 656 // it 657 var user 658 = WebConsoleUtils.userFromSession(connection.session()) 659 .map(ConsoleUser::getName).orElse(null); 660 var toBeUsedByConlet = vmDef.assignedFrom() 661 .map(p -> p.equals(model.get().name())).orElse(false) 662 && vmDef.assignedTo().map(u -> u.equals(user)) 663 .orElse(false); 664 if (!Objects.areEqual(model.get().assignedVm(), 665 vmDef.name()) && !toBeUsedByConlet) { 666 continue; 667 } 668 669 // Now unassigned if VM is deleted or no longer to be used 670 if (event.type() == K8sObserver.ResponseType.DELETED 671 || !toBeUsedByConlet) { 672 updateVmDef(connection, model.get(), null); 673 continue; 674 } 675 } 676 677 // Full update because permissions may have changed 678 updatePreview(connection, model.get(), vmDef); 679 } 680 } 681 } 682 683 /** 684 * On vm pool changed. 685 * 686 * @param event the event 687 * @throws InterruptedException the interrupted exception 688 */ 689 @Handler(namedChannels = "manager") 690 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 691 public void onVmPoolChanged(VmPoolChanged event) 692 throws InterruptedException { 693 var poolName = event.vmPool().name(); 694 // Update known conlets 695 for (var entry : conletIdsByConsoleConnection().entrySet()) { 696 var connection = entry.getKey(); 697 for (var conletId : entry.getValue()) { 698 var model = stateFromSession(connection.session(), conletId); 699 if (model.isEmpty() 700 || model.get().mode() != ResourceModel.Mode.POOL 701 || !Objects.areEqual(model.get().name(), poolName)) { 702 continue; 703 } 704 if (event.deleted() 705 || permissions(event.vmPool(), connection.session()) 706 .isEmpty()) { 707 connection.respond( 708 new DeleteConlet(conletId, Collections.emptySet())); 709 continue; 710 } 711 updateConfig(connection, model.get(), null); 712 } 713 } 714 } 715 716 @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", 717 "PMD.AvoidLiteralsInIfCondition" }) 718 @Override 719 protected void doUpdateConletState(NotifyConletModel event, 720 ConsoleConnection channel, ResourceModel model) throws Exception { 721 event.stop(); 722 if ("selectedResource".equals(event.method())) { 723 selectResource(event, channel, model); 724 return; 725 } 726 727 Optional<VmData> vmData = getVmData(model, channel); 728 if (vmData.isEmpty()) { 729 if (model.mode() == ResourceModel.Mode.VM) { 730 return; 731 } 732 if ("start".equals(event.method())) { 733 // Assign a VM. 734 var user = WebConsoleUtils.userFromSession(channel.session()) 735 .map(ConsoleUser::getName).orElse(null); 736 vmData = Optional.ofNullable(appPipeline 737 .fire(new AssignVm(model.name(), user)).get()); 738 if (vmData.isEmpty()) { 739 ResourceBundle resourceBundle 740 = resourceBundle(channel.locale()); 741 channel.respond(new DisplayNotification( 742 resourceBundle.getString("poolEmptyNotification"), 743 Map.of("autoClose", 10_000, "type", "Error"))); 744 return; 745 } 746 } 747 } 748 749 // Handle command for selected VM 750 var vmChannel = vmData.get().channel(); 751 var vmDef = vmData.get().definition(); 752 var vmName = vmDef.metadata().getName(); 753 var perms = permissions(model, channel.session(), null, vmDef); 754 var resourceBundle = resourceBundle(channel.locale()); 755 switch (event.method()) { 756 case "start": 757 if (perms.contains(VmDefinition.Permission.START)) { 758 fire(new ModifyVm(vmName, "state", "Running", vmChannel)); 759 } 760 break; 761 case "stop": 762 if (perms.contains(VmDefinition.Permission.STOP)) { 763 fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); 764 } 765 break; 766 case "reset": 767 if (perms.contains(VmDefinition.Permission.RESET)) { 768 confirmReset(event, channel, model, resourceBundle); 769 } 770 break; 771 case "resetConfirmed": 772 if (perms.contains(VmDefinition.Permission.RESET)) { 773 fire(new ResetVm(vmName), vmChannel); 774 } 775 break; 776 case "openConsole": 777 if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { 778 openConsole(channel, model, vmChannel, vmDef, perms); 779 } 780 break; 781 default:// ignore 782 break; 783 } 784 } 785 786 private void confirmReset(NotifyConletModel event, 787 ConsoleConnection channel, ResourceModel model, 788 ResourceBundle resourceBundle) throws TemplateNotFoundException, 789 MalformedTemplateNameException, ParseException, IOException { 790 Template tpl = freemarkerConfig() 791 .getTemplate("VmAccess-confirmReset.ftl.html"); 792 channel.respond(new OpenModalDialog(type(), model.getConletId(), 793 processTemplate(event, tpl, 794 fmModel(event, channel, model.getConletId(), model))) 795 .addOption("cancelable", true).addOption("closeLabel", "") 796 .addOption("title", 797 resourceBundle.getString("confirmResetTitle"))); 798 } 799 800 private void openConsole(ConsoleConnection channel, ResourceModel model, 801 VmChannel vmChannel, VmDefinition vmDef, Set<Permission> perms) { 802 var resourceBundle = resourceBundle(channel.locale()); 803 var user = WebConsoleUtils.userFromSession(channel.session()) 804 .map(ConsoleUser::getName).orElse(""); 805 if (!vmDef.consoleAccessible(user, perms)) { 806 channel.respond(new DisplayNotification( 807 resourceBundle.getString("consoleTakenNotification"), 808 Map.of("autoClose", 5_000, "type", "Warning"))); 809 return; 810 } 811 var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user, 812 model.mode() == ResourceModel.Mode.POOL), 813 e -> gotPassword(channel, model, vmDef, e)); 814 fire(pwQuery, vmChannel); 815 } 816 817 private void gotPassword(ConsoleConnection channel, ResourceModel model, 818 VmDefinition vmDef, PrepareConsole event) { 819 if (!event.passwordAvailable()) { 820 return; 821 } 822 vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), 823 preferredIpVersion, deleteConnectionFile)) 824 .ifPresent(cf -> channel.respond(new NotifyConletView(type(), 825 model.getConletId(), "openConsole", cf))); 826 } 827 828 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 829 "PMD.UseLocaleWithCaseConversions" }) 830 private void selectResource(NotifyConletModel event, 831 ConsoleConnection channel, ResourceModel model) 832 throws JsonProcessingException, InterruptedException { 833 try { 834 model.setMode(ResourceModel.Mode 835 .valueOf(event.<String> param(0).toUpperCase())); 836 model.setName(event.param(1)); 837 String jsonState = objectMapper.writeValueAsString(model); 838 channel.respond(new KeyValueStoreUpdate().update(storagePath( 839 channel.session(), model.getConletId()), jsonState)); 840 updatePreview(channel, model, 841 getVmData(model, channel).map(VmData::definition).orElse(null)); 842 } catch (IllegalArgumentException e) { 843 logger.warning(() -> "Invalid resource type: " + e.getMessage()); 844 } 845 } 846 847 @Override 848 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 849 String conletId) throws Exception { 850 return true; 851 } 852 853 /** 854 * The Class AccessModel. 855 */ 856 @SuppressWarnings("PMD.DataClass") 857 public static class ResourceModel extends ConletBaseModel { 858 859 /** 860 * The Enum ResourceType. 861 */ 862 @SuppressWarnings("PMD.ShortVariable") 863 public enum Mode { 864 VM, POOL 865 } 866 867 private Mode mode; 868 private String name; 869 private String assignedVm; 870 871 /** 872 * Instantiates a new resource model. 873 * 874 * @param conletId the conlet id 875 */ 876 public ResourceModel(@JsonProperty("conletId") String conletId) { 877 super(conletId); 878 } 879 880 /** 881 * Returns the mode. 882 * 883 * @return the resourceType 884 */ 885 @JsonGetter("mode") 886 public Mode mode() { 887 return mode; 888 } 889 890 /** 891 * Sets the mode. 892 * 893 * @param mode the resource mode to set 894 */ 895 public void setMode(Mode mode) { 896 this.mode = mode; 897 } 898 899 /** 900 * Gets the resource name. 901 * 902 * @return the string 903 */ 904 @JsonGetter("name") 905 public String name() { 906 return name; 907 } 908 909 /** 910 * Sets the name. 911 * 912 * @param name the resource name to set 913 */ 914 public void setName(String name) { 915 this.name = name; 916 } 917 918 /** 919 * Gets the assigned vm. 920 * 921 * @return the string 922 */ 923 @JsonGetter("assignedVm") 924 public String assignedVm() { 925 return assignedVm; 926 } 927 928 /** 929 * Sets the assigned vm. 930 * 931 * @param name the assigned vm 932 */ 933 public void setAssignedVm(String name) { 934 this.assignedVm = name; 935 } 936 937 @Override 938 public int hashCode() { 939 final int prime = 31; 940 int result = super.hashCode(); 941 result = prime * result + java.util.Objects.hash(mode, name); 942 return result; 943 } 944 945 @Override 946 public boolean equals(Object obj) { 947 if (this == obj) { 948 return true; 949 } 950 if (!super.equals(obj)) { 951 return false; 952 } 953 if (getClass() != obj.getClass()) { 954 return false; 955 } 956 ResourceModel other = (ResourceModel) obj; 957 return mode == other.mode 958 && java.util.Objects.equals(name, other.name); 959 } 960 961 @Override 962 public String toString() { 963 StringBuilder builder = new StringBuilder(50); 964 builder.append("AccessModel [mode=").append(mode) 965 .append(", name=").append(name).append(']'); 966 return builder.toString(); 967 } 968 } 969}