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.vmconlet; 020 021import com.google.gson.JsonObject; 022import freemarker.core.ParseException; 023import freemarker.template.MalformedTemplateNameException; 024import freemarker.template.Template; 025import freemarker.template.TemplateNotFoundException; 026import io.kubernetes.client.custom.Quantity; 027import io.kubernetes.client.custom.Quantity.Format; 028import java.io.IOException; 029import java.math.BigDecimal; 030import java.math.BigInteger; 031import java.time.Duration; 032import java.time.Instant; 033import java.util.HashSet; 034import java.util.Optional; 035import java.util.Set; 036import org.jdrupes.json.JsonBeanDecoder; 037import org.jdrupes.json.JsonDecodeException; 038import org.jdrupes.vmoperator.common.K8sObserver; 039import org.jdrupes.vmoperator.common.VmDefinitionModel; 040import org.jdrupes.vmoperator.manager.events.ChannelCache; 041import org.jdrupes.vmoperator.manager.events.ModifyVm; 042import org.jdrupes.vmoperator.manager.events.VmChannel; 043import org.jdrupes.vmoperator.manager.events.VmDefChanged; 044import org.jdrupes.vmoperator.util.GsonPtr; 045import org.jgrapes.core.Channel; 046import org.jgrapes.core.Event; 047import org.jgrapes.core.Manager; 048import org.jgrapes.core.annotation.Handler; 049import org.jgrapes.webconsole.base.Conlet.RenderMode; 050import org.jgrapes.webconsole.base.ConletBaseModel; 051import org.jgrapes.webconsole.base.ConsoleConnection; 052import org.jgrapes.webconsole.base.events.AddConletRequest; 053import org.jgrapes.webconsole.base.events.AddConletType; 054import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 055import org.jgrapes.webconsole.base.events.ConsoleReady; 056import org.jgrapes.webconsole.base.events.NotifyConletModel; 057import org.jgrapes.webconsole.base.events.NotifyConletView; 058import org.jgrapes.webconsole.base.events.RenderConlet; 059import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 060import org.jgrapes.webconsole.base.events.SetLocale; 061import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 062 063/** 064 * The Class VmConlet. 065 */ 066@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 067public class VmConlet extends FreeMarkerConlet<VmConlet.VmsModel> { 068 069 private static final Set<RenderMode> MODES = RenderMode.asSet( 070 RenderMode.Preview, RenderMode.View); 071 private final ChannelCache<String, VmChannel, 072 VmDefinitionModel> channelManager = new ChannelCache<>(); 073 private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); 074 private Summary cachedSummary; 075 076 /** 077 * The periodically generated update event. 078 */ 079 public static class Update extends Event<Void> { 080 } 081 082 /** 083 * Creates a new component with its channel set to the given channel. 084 * 085 * @param componentChannel the channel that the component's handlers listen 086 * on by default and that {@link Manager#fire(Event, Channel...)} 087 * sends the event to 088 */ 089 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 090 public VmConlet(Channel componentChannel) { 091 super(componentChannel); 092 setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update()); 093 } 094 095 /** 096 * On {@link ConsoleReady}, fire the {@link AddConletType}. 097 * 098 * @param event the event 099 * @param channel the channel 100 * @throws TemplateNotFoundException the template not found exception 101 * @throws MalformedTemplateNameException the malformed template name 102 * exception 103 * @throws ParseException the parse exception 104 * @throws IOException Signals that an I/O exception has occurred. 105 */ 106 @Handler 107 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 108 throws TemplateNotFoundException, MalformedTemplateNameException, 109 ParseException, IOException { 110 // Add conlet resources to page 111 channel.respond(new AddConletType(type()) 112 .setDisplayNames( 113 localizations(channel.supportedLocales(), "conletName")) 114 .addRenderMode(RenderMode.Preview) 115 .addScript(new ScriptResource().setScriptType("module") 116 .setScriptUri(event.renderSupport().conletResource( 117 type(), "VmConlet-functions.js")))); 118 } 119 120 @Override 121 protected Optional<VmsModel> createNewState(AddConletRequest event, 122 ConsoleConnection connection, String conletId) throws Exception { 123 return Optional.of(new VmsModel(conletId)); 124 } 125 126 @Override 127 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 128 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 129 ConsoleConnection channel, String conletId, VmsModel conletState) 130 throws Exception { 131 Set<RenderMode> renderedAs = new HashSet<>(); 132 boolean sendVmInfos = false; 133 if (event.renderAs().contains(RenderMode.Preview)) { 134 Template tpl 135 = freemarkerConfig().getTemplate("VmConlet-preview.ftl.html"); 136 channel.respond(new RenderConlet(type(), conletId, 137 processTemplate(event, tpl, 138 fmModel(event, channel, conletId, conletState))) 139 .setRenderAs( 140 RenderMode.Preview.addModifiers(event.renderAs())) 141 .setSupportedModes(MODES)); 142 renderedAs.add(RenderMode.Preview); 143 channel.respond(new NotifyConletView(type(), 144 conletId, "summarySeries", summarySeries.entries())); 145 var summary = evaluateSummary(false); 146 channel.respond(new NotifyConletView(type(), 147 conletId, "updateSummary", summary)); 148 sendVmInfos = true; 149 } 150 if (event.renderAs().contains(RenderMode.View)) { 151 Template tpl 152 = freemarkerConfig().getTemplate("VmConlet-view.ftl.html"); 153 channel.respond(new RenderConlet(type(), conletId, 154 processTemplate(event, tpl, 155 fmModel(event, channel, conletId, conletState))) 156 .setRenderAs( 157 RenderMode.View.addModifiers(event.renderAs())) 158 .setSupportedModes(MODES)); 159 renderedAs.add(RenderMode.View); 160 sendVmInfos = true; 161 } 162 if (sendVmInfos) { 163 for (var vmDef : channelManager.associated()) { 164 var def 165 = JsonBeanDecoder.create(vmDef.data().toString()) 166 .readObject(); 167 channel.respond(new NotifyConletView(type(), 168 conletId, "updateVm", def)); 169 } 170 } 171 172 return renderedAs; 173 } 174 175 /** 176 * Track the VM definitions. 177 * 178 * @param event the event 179 * @param channel the channel 180 * @throws JsonDecodeException the json decode exception 181 * @throws IOException 182 */ 183 @Handler(namedChannels = "manager") 184 @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", 185 "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", 186 "PMD.ConfusingArgumentToVarargsMethod" }) 187 public void onVmDefChanged(VmDefChanged event, VmChannel channel) 188 throws JsonDecodeException, IOException { 189 var vmName = event.vmDefinition().getMetadata().getName(); 190 if (event.type() == K8sObserver.ResponseType.DELETED) { 191 channelManager.remove(vmName); 192 for (var entry : conletIdsByConsoleConnection().entrySet()) { 193 for (String conletId : entry.getValue()) { 194 entry.getKey().respond(new NotifyConletView(type(), 195 conletId, "removeVm", vmName)); 196 } 197 } 198 } else { 199 var vmDef = new VmDefinitionModel(channel.client().getJSON() 200 .getGson(), cleanup(event.vmDefinition().data())); 201 channelManager.put(vmName, channel, vmDef); 202 var def = JsonBeanDecoder.create(vmDef.data().toString()) 203 .readObject(); 204 for (var entry : conletIdsByConsoleConnection().entrySet()) { 205 for (String conletId : entry.getValue()) { 206 entry.getKey().respond(new NotifyConletView(type(), 207 conletId, "updateVm", def)); 208 } 209 } 210 } 211 212 var summary = evaluateSummary(true); 213 summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); 214 for (var entry : conletIdsByConsoleConnection().entrySet()) { 215 for (String conletId : entry.getValue()) { 216 entry.getKey().respond(new NotifyConletView(type(), 217 conletId, "updateSummary", summary)); 218 } 219 } 220 } 221 222 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 223 private JsonObject cleanup(JsonObject vmDef) { 224 // Clone and remove managed fields 225 var json = vmDef.deepCopy(); 226 GsonPtr.to(json).to("metadata").get(JsonObject.class) 227 .remove("managedFields"); 228 229 // Convert RAM sizes to unitless numbers 230 var vmSpec = GsonPtr.to(json).to("spec", "vm"); 231 vmSpec.set("maximumRam", Quantity.fromString( 232 vmSpec.getAsString("maximumRam").orElse("0")).getNumber() 233 .toBigInteger()); 234 vmSpec.set("currentRam", Quantity.fromString( 235 vmSpec.getAsString("currentRam").orElse("0")).getNumber() 236 .toBigInteger()); 237 var status = GsonPtr.to(json).to("status"); 238 status.set("ram", Quantity.fromString( 239 status.getAsString("ram").orElse("0")).getNumber() 240 .toBigInteger()); 241 return json; 242 } 243 244 /** 245 * Handle the periodic update event by sending {@link NotifyConletView} 246 * events. 247 * 248 * @param event the event 249 * @param connection the console connection 250 */ 251 @Handler 252 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 253 public void onUpdate(Update event, ConsoleConnection connection) { 254 var summary = evaluateSummary(false); 255 summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); 256 for (String conletId : conletIds(connection)) { 257 connection.respond(new NotifyConletView(type(), 258 conletId, "updateSummary", summary)); 259 } 260 } 261 262 /** 263 * The Class Summary. 264 */ 265 @SuppressWarnings("PMD.DataClass") 266 public static class Summary { 267 268 /** The total vms. */ 269 public int totalVms; 270 271 /** The running vms. */ 272 public int runningVms; 273 274 /** The used cpus. */ 275 public int usedCpus; 276 277 /** The used ram. */ 278 public BigInteger usedRam = BigInteger.ZERO; 279 280 /** 281 * Gets the total vms. 282 * 283 * @return the totalVms 284 */ 285 public int getTotalVms() { 286 return totalVms; 287 } 288 289 /** 290 * Gets the running vms. 291 * 292 * @return the runningVms 293 */ 294 public int getRunningVms() { 295 return runningVms; 296 } 297 298 /** 299 * Gets the used cpus. 300 * 301 * @return the usedCpus 302 */ 303 public int getUsedCpus() { 304 return usedCpus; 305 } 306 307 /** 308 * Gets the used ram. Returned as String for Json rendering. 309 * 310 * @return the usedRam 311 */ 312 public String getUsedRam() { 313 return usedRam.toString(); 314 } 315 316 } 317 318 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 319 private Summary evaluateSummary(boolean force) { 320 if (!force && cachedSummary != null) { 321 return cachedSummary; 322 } 323 Summary summary = new Summary(); 324 for (var vmDef : channelManager.associated()) { 325 summary.totalVms += 1; 326 var status = GsonPtr.to(vmDef.data()).to("status"); 327 summary.usedCpus += status.getAsInt("cpus").orElse(0); 328 summary.usedRam = summary.usedRam.add(status.getAsString("ram") 329 .map(BigInteger::new).orElse(BigInteger.ZERO)); 330 for (var c : status.getAsListOf(JsonObject.class, "conditions")) { 331 if ("Running".equals(GsonPtr.to(c).getAsString("type") 332 .orElse(null)) 333 && "True".equals(GsonPtr.to(c).getAsString("status") 334 .orElse(null))) { 335 summary.runningVms += 1; 336 } 337 } 338 } 339 cachedSummary = summary; 340 return summary; 341 } 342 343 @Override 344 @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor") 345 protected void doUpdateConletState(NotifyConletModel event, 346 ConsoleConnection channel, VmsModel conletState) 347 throws Exception { 348 event.stop(); 349 var vmName = event.params().asString(0); 350 var vmChannel = channelManager.channel(vmName).orElse(null); 351 if (vmChannel == null) { 352 return; 353 } 354 switch (event.method()) { 355 case "start": 356 fire(new ModifyVm(vmName, "state", "Running", vmChannel)); 357 break; 358 case "stop": 359 fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); 360 break; 361 case "cpus": 362 fire(new ModifyVm(vmName, "currentCpus", 363 new BigDecimal(event.params().asDouble(1)).toBigInteger(), 364 vmChannel)); 365 break; 366 case "ram": 367 fire(new ModifyVm(vmName, "currentRam", 368 new Quantity(new BigDecimal(event.params().asDouble(1)), 369 Format.BINARY_SI).toSuffixedString(), 370 vmChannel)); 371 break; 372 default:// ignore 373 break; 374 } 375 } 376 377 @Override 378 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 379 String conletId) throws Exception { 380 return true; 381 } 382 383 /** 384 * The Class VmsModel. 385 */ 386 public class VmsModel extends ConletBaseModel { 387 388 /** 389 * Instantiates a new vms model. 390 * 391 * @param conletId the conlet id 392 */ 393 public VmsModel(String conletId) { 394 super(conletId); 395 } 396 397 } 398}