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.ChannelDictionary.Value; 046import org.jdrupes.vmoperator.manager.events.ChannelTracker; 047import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; 048import org.jdrupes.vmoperator.manager.events.ModifyVm; 049import org.jdrupes.vmoperator.manager.events.ResetVm; 050import org.jdrupes.vmoperator.manager.events.VmChannel; 051import org.jdrupes.vmoperator.manager.events.VmResourceChanged; 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.CouplingBetweenObjects", "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" }) 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 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 181 ConsoleConnection channel, String conletId, VmsModel conletState) 182 throws Exception { 183 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 184 boolean sendVmInfos = false; 185 if (event.renderAs().contains(RenderMode.Preview)) { 186 Template tpl 187 = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html"); 188 channel.respond(new RenderConlet(type(), conletId, 189 processTemplate(event, tpl, 190 fmModel(event, channel, conletId, conletState))) 191 .setRenderAs( 192 RenderMode.Preview.addModifiers(event.renderAs())) 193 .setSupportedModes(MODES)); 194 renderedAs.add(RenderMode.Preview); 195 channel.respond(new NotifyConletView(type(), 196 conletId, "summarySeries", summarySeries.entries())); 197 var summary = evaluateSummary(false); 198 channel.respond(new NotifyConletView(type(), 199 conletId, "updateSummary", summary)); 200 sendVmInfos = true; 201 } 202 if (event.renderAs().contains(RenderMode.View)) { 203 Template tpl 204 = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html"); 205 channel.respond(new RenderConlet(type(), conletId, 206 processTemplate(event, tpl, 207 fmModel(event, channel, conletId, conletState))) 208 .setRenderAs( 209 RenderMode.View.addModifiers(event.renderAs())) 210 .setSupportedModes(MODES)); 211 renderedAs.add(RenderMode.View); 212 sendVmInfos = true; 213 } 214 if (sendVmInfos) { 215 for (var item : channelTracker.values()) { 216 updateVm(channel, conletId, item.associated()); 217 } 218 } 219 return renderedAs; 220 } 221 222 private void updateVm(ConsoleConnection channel, String conletId, 223 VmDefinition vmDef) { 224 var user = WebConsoleUtils.userFromSession(channel.session()) 225 .map(ConsoleUser::getName).orElse(null); 226 var roles = WebConsoleUtils.rolesFromSession(channel.session()) 227 .stream().map(ConsoleRole::getName).toList(); 228 channel.respond(new NotifyConletView(type(), conletId, "updateVm", 229 simplifiedVmDefinition(vmDef, user, roles))); 230 } 231 232 private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef, 233 String user, List<String> roles) { 234 // Convert RAM sizes to unitless numbers 235 var spec = DataPath.deepCopy(vmDef.spec()); 236 spec.remove("cloudInit"); 237 var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get(); 238 vmSpec.remove("networks"); 239 vmSpec.remove("disks"); 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 var perms = vmDef.permissionsFor(user, roles); 253 return Map.of("metadata", 254 Map.of("namespace", vmDef.namespace(), 255 "name", vmDef.name()), 256 "spec", spec, 257 "status", status, 258 "nodeName", vmDef.extra().nodeName(), 259 "consoleAccessible", vmDef.consoleAccessible(user, perms), 260 "permissions", perms); 261 } 262 263 /** 264 * Track the VM definitions. 265 * 266 * @param event the event 267 * @param channel the channel 268 * @throws IOException 269 */ 270 @Handler(namedChannels = "manager") 271 @SuppressWarnings({ "PMD.CognitiveComplexity", 272 "PMD.AvoidInstantiatingObjectsInLoops" }) 273 public void onVmResourceChanged(VmResourceChanged 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 private Summary evaluateSummary(boolean force) { 379 if (!force && cachedSummary != null) { 380 return cachedSummary; 381 } 382 Summary summary = new Summary(); 383 for (var vmDef : channelTracker.associated()) { 384 summary.totalVms += 1; 385 summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS) 386 .map(Number::intValue).orElse(0); 387 summary.usedRam = summary.usedRam 388 .add(vmDef.<String> fromStatus(Status.RAM) 389 .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) 390 .orElse(BigInteger.ZERO)); 391 if (vmDef.conditionStatus("Running").orElse(false)) { 392 summary.runningVms += 1; 393 } 394 } 395 cachedSummary = summary; 396 return summary; 397 } 398 399 @Override 400 @SuppressWarnings({ "PMD.NcssCount" }) 401 protected void doUpdateConletState(NotifyConletModel event, 402 ConsoleConnection channel, VmsModel model) throws Exception { 403 event.stop(); 404 String vmName = event.param(0); 405 var value = channelTracker.value(vmName); 406 var vmChannel = value.map(Value::channel).orElse(null); 407 var vmDef = value.map(Value::associated).orElse(null); 408 if (vmDef == null) { 409 return; 410 } 411 var user = WebConsoleUtils.userFromSession(channel.session()) 412 .map(ConsoleUser::getName).orElse(""); 413 var roles = WebConsoleUtils.rolesFromSession(channel.session()) 414 .stream().map(ConsoleRole::getName).toList(); 415 var perms = vmDef.permissionsFor(user, roles); 416 switch (event.method()) { 417 case "start": 418 if (perms.contains(VmDefinition.Permission.START)) { 419 vmChannel.fire(new ModifyVm(vmName, "state", "Running")); 420 } 421 break; 422 case "stop": 423 if (perms.contains(VmDefinition.Permission.STOP)) { 424 vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); 425 } 426 break; 427 case "reset": 428 if (perms.contains(VmDefinition.Permission.RESET)) { 429 confirmReset(event, channel, model, vmName); 430 } 431 break; 432 case "resetConfirmed": 433 if (perms.contains(VmDefinition.Permission.RESET)) { 434 vmChannel.fire(new ResetVm(vmName)); 435 } 436 break; 437 case "openConsole": 438 openConsole(channel, model, vmChannel, vmDef, user, perms); 439 break; 440 case "cpus": 441 vmChannel.fire(new ModifyVm(vmName, "currentCpus", 442 new BigDecimal(event.param(1).toString()).toBigInteger())); 443 break; 444 case "ram": 445 vmChannel.fire(new ModifyVm(vmName, "currentRam", 446 new Quantity(new BigDecimal(event.param(1).toString()), 447 Format.BINARY_SI).toSuffixedString())); 448 break; 449 default:// ignore 450 break; 451 } 452 } 453 454 private void confirmReset(NotifyConletModel event, 455 ConsoleConnection channel, VmsModel model, String vmName) 456 throws TemplateNotFoundException, 457 MalformedTemplateNameException, ParseException, IOException { 458 Template tpl = freemarkerConfig() 459 .getTemplate("VmMgmt-confirmReset.ftl.html"); 460 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 461 var fmModel = fmModel(event, channel, model.getConletId(), model); 462 fmModel.put("vmName", vmName); 463 channel.respond(new OpenModalDialog(type(), model.getConletId(), 464 processTemplate(event, tpl, fmModel)) 465 .addOption("cancelable", true).addOption("closeLabel", "") 466 .addOption("title", 467 resourceBundle.getString("confirmResetTitle"))); 468 } 469 470 private void openConsole(ConsoleConnection channel, VmsModel model, 471 VmChannel vmChannel, VmDefinition vmDef, String user, 472 Set<Permission> perms) { 473 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 474 if (!vmDef.consoleAccessible(user, perms)) { 475 channel.respond(new DisplayNotification( 476 resourceBundle.getString("consoleTakenNotification"), 477 Map.of("autoClose", 5_000, "type", "Warning"))); 478 return; 479 } 480 var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), 481 e -> gotPassword(channel, model, vmDef, e)); 482 vmChannel.fire(pwQuery); 483 } 484 485 private void gotPassword(ConsoleConnection channel, VmsModel model, 486 VmDefinition vmDef, GetDisplaySecret event) { 487 if (!event.secretAvailable()) { 488 return; 489 } 490 vmDef.extra().connectionFile(event.secret(), 491 preferredIpVersion, deleteConnectionFile).ifPresent( 492 cf -> channel.respond(new NotifyConletView(type(), 493 model.getConletId(), "openConsole", cf))); 494 } 495 496 @Override 497 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 498 String conletId) throws Exception { 499 return true; 500 } 501 502 /** 503 * The Class VmsModel. 504 */ 505 public class VmsModel extends ConletBaseModel { 506 507 /** 508 * Instantiates a new vms model. 509 * 510 * @param conletId the conlet id 511 */ 512 public VmsModel(String conletId) { 513 super(conletId); 514 } 515 516 } 517}