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