001/* 002 * VM-Operator 003 * Copyright (C) 2023 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.vmmgmt; 020 021import freemarker.core.ParseException; 022import freemarker.template.MalformedTemplateNameException; 023import freemarker.template.Template; 024import freemarker.template.TemplateNotFoundException; 025import io.kubernetes.client.custom.Quantity; 026import io.kubernetes.client.custom.Quantity.Format; 027import java.io.IOException; 028import java.math.BigDecimal; 029import java.math.BigInteger; 030import java.net.Inet4Address; 031import java.net.Inet6Address; 032import java.time.Duration; 033import java.time.Instant; 034import java.util.Collections; 035import java.util.EnumSet; 036import java.util.List; 037import java.util.Map; 038import java.util.Optional; 039import java.util.ResourceBundle; 040import java.util.Set; 041import org.jdrupes.vmoperator.common.Constants.Status; 042import org.jdrupes.vmoperator.common.K8sObserver; 043import org.jdrupes.vmoperator.common.VmDefinition; 044import org.jdrupes.vmoperator.common.VmDefinition.Permission; 045import org.jdrupes.vmoperator.manager.events.ChannelTracker; 046import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; 047import org.jdrupes.vmoperator.manager.events.ModifyVm; 048import org.jdrupes.vmoperator.manager.events.ResetVm; 049import org.jdrupes.vmoperator.manager.events.VmChannel; 050import org.jdrupes.vmoperator.manager.events.VmResourceChanged; 051import org.jdrupes.vmoperator.util.DataPath; 052import org.jgrapes.core.Channel; 053import org.jgrapes.core.Event; 054import org.jgrapes.core.Manager; 055import org.jgrapes.core.annotation.Handler; 056import org.jgrapes.util.events.ConfigurationUpdate; 057import org.jgrapes.webconsole.base.Conlet.RenderMode; 058import org.jgrapes.webconsole.base.ConletBaseModel; 059import org.jgrapes.webconsole.base.ConsoleConnection; 060import org.jgrapes.webconsole.base.ConsoleRole; 061import org.jgrapes.webconsole.base.ConsoleUser; 062import org.jgrapes.webconsole.base.WebConsoleUtils; 063import org.jgrapes.webconsole.base.events.AddConletType; 064import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 065import org.jgrapes.webconsole.base.events.ConsoleReady; 066import org.jgrapes.webconsole.base.events.DisplayNotification; 067import org.jgrapes.webconsole.base.events.NotifyConletModel; 068import org.jgrapes.webconsole.base.events.NotifyConletView; 069import org.jgrapes.webconsole.base.events.OpenModalDialog; 070import org.jgrapes.webconsole.base.events.RenderConlet; 071import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 072import org.jgrapes.webconsole.base.events.SetLocale; 073import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 074 075/** 076 * The Class {@link VmMgmt}. 077 */ 078@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects", 079 "PMD.ExcessiveImports" }) 080public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> { 081 082 private Class<?> preferredIpVersion = Inet4Address.class; 083 private boolean deleteConnectionFile = true; 084 private static final Set<RenderMode> MODES = RenderMode.asSet( 085 RenderMode.Preview, RenderMode.View); 086 private final ChannelTracker<String, VmChannel, 087 VmDefinition> channelTracker = new ChannelTracker<>(); 088 private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); 089 private Summary cachedSummary; 090 091 /** 092 * The periodically generated update event. 093 */ 094 public static class Update extends Event<Void> { 095 } 096 097 /** 098 * Creates a new component with its channel set to the given channel. 099 * 100 * @param componentChannel the channel that the component's handlers listen 101 * on by default and that {@link Manager#fire(Event, Channel...)} 102 * sends the event to 103 */ 104 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 105 public VmMgmt(Channel componentChannel) { 106 super(componentChannel); 107 setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update()); 108 } 109 110 /** 111 * Configure the component. 112 * 113 * @param event the event 114 */ 115 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 116 @Handler 117 public void onConfigurationUpdate(ConfigurationUpdate event) { 118 event.structured("/Manager/GuiHttpServer" 119 + "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess") 120 .ifPresent(c -> { 121 try { 122 var dispRes = (Map<String, Object>) c 123 .getOrDefault("displayResource", 124 Collections.emptyMap()); 125 switch ((String) dispRes.getOrDefault("preferredIpVersion", 126 "")) { 127 case "ipv6": 128 preferredIpVersion = Inet6Address.class; 129 break; 130 case "ipv4": 131 default: 132 preferredIpVersion = Inet4Address.class; 133 break; 134 } 135 136 // Delete connection file 137 deleteConnectionFile 138 = Optional.ofNullable(c.get("deleteConnectionFile")) 139 .filter(v -> v instanceof String) 140 .map(v -> (String) v) 141 .map(Boolean::parseBoolean).orElse(true); 142 } catch (ClassCastException e) { 143 logger.config("Malformed configuration: " + e.getMessage()); 144 } 145 }); 146 } 147 148 /** 149 * On {@link ConsoleReady}, fire the {@link AddConletType}. 150 * 151 * @param event the event 152 * @param channel the channel 153 * @throws TemplateNotFoundException the template not found exception 154 * @throws MalformedTemplateNameException the malformed template name 155 * exception 156 * @throws ParseException the parse exception 157 * @throws IOException Signals that an I/O exception has occurred. 158 */ 159 @Handler 160 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 161 throws TemplateNotFoundException, MalformedTemplateNameException, 162 ParseException, IOException { 163 // Add conlet resources to page 164 channel.respond(new AddConletType(type()) 165 .setDisplayNames( 166 localizations(channel.supportedLocales(), "conletName")) 167 .addRenderMode(RenderMode.Preview) 168 .addScript(new ScriptResource().setScriptType("module") 169 .setScriptUri(event.renderSupport().conletResource( 170 type(), "VmMgmt-functions.js")))); 171 } 172 173 @Override 174 protected Optional<VmsModel> createStateRepresentation(Event<?> event, 175 ConsoleConnection connection, String conletId) throws Exception { 176 return Optional.of(new VmsModel(conletId)); 177 } 178 179 @Override 180 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 181 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 182 ConsoleConnection channel, String conletId, VmsModel conletState) 183 throws Exception { 184 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 185 boolean sendVmInfos = false; 186 if (event.renderAs().contains(RenderMode.Preview)) { 187 Template tpl 188 = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html"); 189 channel.respond(new RenderConlet(type(), conletId, 190 processTemplate(event, tpl, 191 fmModel(event, channel, conletId, conletState))) 192 .setRenderAs( 193 RenderMode.Preview.addModifiers(event.renderAs())) 194 .setSupportedModes(MODES)); 195 renderedAs.add(RenderMode.Preview); 196 channel.respond(new NotifyConletView(type(), 197 conletId, "summarySeries", summarySeries.entries())); 198 var summary = evaluateSummary(false); 199 channel.respond(new NotifyConletView(type(), 200 conletId, "updateSummary", summary)); 201 sendVmInfos = true; 202 } 203 if (event.renderAs().contains(RenderMode.View)) { 204 Template tpl 205 = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html"); 206 channel.respond(new RenderConlet(type(), conletId, 207 processTemplate(event, tpl, 208 fmModel(event, channel, conletId, conletState))) 209 .setRenderAs( 210 RenderMode.View.addModifiers(event.renderAs())) 211 .setSupportedModes(MODES)); 212 renderedAs.add(RenderMode.View); 213 sendVmInfos = true; 214 } 215 if (sendVmInfos) { 216 for (var item : channelTracker.values()) { 217 updateVm(channel, conletId, item.associated()); 218 } 219 } 220 return renderedAs; 221 } 222 223 private void updateVm(ConsoleConnection channel, String conletId, 224 VmDefinition vmDef) { 225 var user = WebConsoleUtils.userFromSession(channel.session()) 226 .map(ConsoleUser::getName).orElse(null); 227 var roles = WebConsoleUtils.rolesFromSession(channel.session()) 228 .stream().map(ConsoleRole::getName).toList(); 229 channel.respond(new NotifyConletView(type(), conletId, "updateVm", 230 simplifiedVmDefinition(vmDef, user, roles))); 231 } 232 233 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 234 private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef, 235 String user, List<String> roles) { 236 // Convert RAM sizes to unitless numbers 237 var spec = DataPath.deepCopy(vmDef.spec()); 238 spec.remove("cloudInit"); 239 var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get(); 240 vmSpec.remove("networks"); 241 vmSpec.remove("disks"); 242 vmSpec.put("maximumRam", Quantity.fromString( 243 DataPath.<String> get(vmSpec, "maximumRam").orElse("0")).getNumber() 244 .toBigInteger()); 245 vmSpec.put("currentRam", Quantity.fromString( 246 DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber() 247 .toBigInteger()); 248 var status = DataPath.deepCopy(vmDef.status()); 249 status.put(Status.RAM, Quantity.fromString( 250 DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber() 251 .toBigInteger()); 252 253 // Build result 254 var perms = vmDef.permissionsFor(user, roles); 255 return Map.of("metadata", 256 Map.of("namespace", vmDef.namespace(), 257 "name", vmDef.name()), 258 "spec", spec, 259 "status", status, 260 "nodeName", vmDef.extra().nodeName(), 261 "consoleAccessible", vmDef.consoleAccessible(user, perms), 262 "permissions", perms); 263 } 264 265 /** 266 * Track the VM definitions. 267 * 268 * @param event the event 269 * @param channel the channel 270 * @throws IOException 271 */ 272 @Handler(namedChannels = "manager") 273 @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", 274 "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", 275 "PMD.ConfusingArgumentToVarargsMethod" }) 276 public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) 277 throws IOException { 278 var vmName = event.vmDefinition().name(); 279 if (event.type() == K8sObserver.ResponseType.DELETED) { 280 channelTracker.remove(vmName); 281 for (var entry : conletIdsByConsoleConnection().entrySet()) { 282 for (String conletId : entry.getValue()) { 283 entry.getKey().respond(new NotifyConletView(type(), 284 conletId, "removeVm", vmName)); 285 } 286 } 287 } else { 288 var vmDef = event.vmDefinition(); 289 channelTracker.put(vmName, channel, vmDef); 290 for (var entry : conletIdsByConsoleConnection().entrySet()) { 291 for (String conletId : entry.getValue()) { 292 updateVm(entry.getKey(), conletId, vmDef); 293 } 294 } 295 } 296 297 var summary = evaluateSummary(true); 298 summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); 299 for (var entry : conletIdsByConsoleConnection().entrySet()) { 300 for (String conletId : entry.getValue()) { 301 entry.getKey().respond(new NotifyConletView(type(), 302 conletId, "updateSummary", summary)); 303 } 304 } 305 } 306 307 /** 308 * Handle the periodic update event by sending {@link NotifyConletView} 309 * events. 310 * 311 * @param event the event 312 * @param connection the console connection 313 */ 314 @Handler 315 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 316 public void onUpdate(Update event, ConsoleConnection connection) { 317 var summary = evaluateSummary(false); 318 summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); 319 for (String conletId : conletIds(connection)) { 320 connection.respond(new NotifyConletView(type(), 321 conletId, "updateSummary", summary)); 322 } 323 } 324 325 /** 326 * The Class Summary. 327 */ 328 @SuppressWarnings("PMD.DataClass") 329 public static class Summary { 330 331 /** The total vms. */ 332 public int totalVms; 333 334 /** The running vms. */ 335 public long runningVms; 336 337 /** The used cpus. */ 338 public long usedCpus; 339 340 /** The used ram. */ 341 public BigInteger usedRam = BigInteger.ZERO; 342 343 /** 344 * Gets the total vms. 345 * 346 * @return the totalVms 347 */ 348 public int getTotalVms() { 349 return totalVms; 350 } 351 352 /** 353 * Gets the running vms. 354 * 355 * @return the runningVms 356 */ 357 public long getRunningVms() { 358 return runningVms; 359 } 360 361 /** 362 * Gets the used cpus. 363 * 364 * @return the usedCpus 365 */ 366 public long getUsedCpus() { 367 return usedCpus; 368 } 369 370 /** 371 * Gets the used ram. Returned as String for Json rendering. 372 * 373 * @return the usedRam 374 */ 375 public String getUsedRam() { 376 return usedRam.toString(); 377 } 378 379 } 380 381 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 382 "PMD.LambdaCanBeMethodReference" }) 383 private Summary evaluateSummary(boolean force) { 384 if (!force && cachedSummary != null) { 385 return cachedSummary; 386 } 387 Summary summary = new Summary(); 388 for (var vmDef : channelTracker.associated()) { 389 summary.totalVms += 1; 390 summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS) 391 .map(Number::intValue).orElse(0); 392 summary.usedRam = summary.usedRam 393 .add(vmDef.<String> fromStatus(Status.RAM) 394 .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) 395 .orElse(BigInteger.ZERO)); 396 if (vmDef.conditionStatus("Running").orElse(false)) { 397 summary.runningVms += 1; 398 } 399 } 400 cachedSummary = summary; 401 return summary; 402 } 403 404 @Override 405 @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", 406 "PMD.NcssCount" }) 407 protected void doUpdateConletState(NotifyConletModel event, 408 ConsoleConnection channel, VmsModel model) throws Exception { 409 event.stop(); 410 String vmName = event.param(0); 411 var value = channelTracker.value(vmName); 412 var vmChannel = value.map(v -> v.channel()).orElse(null); 413 var vmDef = value.map(v -> v.associated()).orElse(null); 414 if (vmDef == null) { 415 return; 416 } 417 var user = WebConsoleUtils.userFromSession(channel.session()) 418 .map(ConsoleUser::getName).orElse(""); 419 var roles = WebConsoleUtils.rolesFromSession(channel.session()) 420 .stream().map(ConsoleRole::getName).toList(); 421 var perms = vmDef.permissionsFor(user, roles); 422 switch (event.method()) { 423 case "start": 424 if (perms.contains(VmDefinition.Permission.START)) { 425 vmChannel.fire(new ModifyVm(vmName, "state", "Running")); 426 } 427 break; 428 case "stop": 429 if (perms.contains(VmDefinition.Permission.STOP)) { 430 vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); 431 } 432 break; 433 case "reset": 434 if (perms.contains(VmDefinition.Permission.RESET)) { 435 confirmReset(event, channel, model, vmName); 436 } 437 break; 438 case "resetConfirmed": 439 if (perms.contains(VmDefinition.Permission.RESET)) { 440 vmChannel.fire(new ResetVm(vmName)); 441 } 442 break; 443 case "openConsole": 444 openConsole(channel, model, vmChannel, vmDef, user, perms); 445 break; 446 case "cpus": 447 vmChannel.fire(new ModifyVm(vmName, "currentCpus", 448 new BigDecimal(event.param(1).toString()).toBigInteger())); 449 break; 450 case "ram": 451 vmChannel.fire(new ModifyVm(vmName, "currentRam", 452 new Quantity(new BigDecimal(event.param(1).toString()), 453 Format.BINARY_SI).toSuffixedString())); 454 break; 455 default:// ignore 456 break; 457 } 458 } 459 460 private void confirmReset(NotifyConletModel event, 461 ConsoleConnection channel, VmsModel model, String vmName) 462 throws TemplateNotFoundException, 463 MalformedTemplateNameException, ParseException, IOException { 464 Template tpl = freemarkerConfig() 465 .getTemplate("VmMgmt-confirmReset.ftl.html"); 466 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 467 var fmModel = fmModel(event, channel, model.getConletId(), model); 468 fmModel.put("vmName", vmName); 469 channel.respond(new OpenModalDialog(type(), model.getConletId(), 470 processTemplate(event, tpl, fmModel)) 471 .addOption("cancelable", true).addOption("closeLabel", "") 472 .addOption("title", 473 resourceBundle.getString("confirmResetTitle"))); 474 } 475 476 private void openConsole(ConsoleConnection channel, VmsModel model, 477 VmChannel vmChannel, VmDefinition vmDef, String user, 478 Set<Permission> perms) { 479 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 480 if (!vmDef.consoleAccessible(user, perms)) { 481 channel.respond(new DisplayNotification( 482 resourceBundle.getString("consoleTakenNotification"), 483 Map.of("autoClose", 5_000, "type", "Warning"))); 484 return; 485 } 486 var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), 487 e -> gotPassword(channel, model, vmDef, e)); 488 vmChannel.fire(pwQuery); 489 } 490 491 private void gotPassword(ConsoleConnection channel, VmsModel model, 492 VmDefinition vmDef, GetDisplaySecret event) { 493 if (!event.secretAvailable()) { 494 return; 495 } 496 vmDef.extra().connectionFile(event.secret(), 497 preferredIpVersion, deleteConnectionFile).ifPresent( 498 cf -> channel.respond(new NotifyConletView(type(), 499 model.getConletId(), "openConsole", cf))); 500 } 501 502 @Override 503 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 504 String conletId) throws Exception { 505 return true; 506 } 507 508 /** 509 * The Class VmsModel. 510 */ 511 public class VmsModel extends ConletBaseModel { 512 513 /** 514 * Instantiates a new vms model. 515 * 516 * @param conletId the conlet id 517 */ 518 public VmsModel(String conletId) { 519 super(conletId); 520 } 521 522 } 523}