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