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