001/* 002 * VM-Operator 003 * Copyright (C) 2023,2025 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.runner.qemu; 020 021import com.fasterxml.jackson.databind.ObjectMapper; 022import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 023import com.google.gson.Gson; 024import com.google.gson.JsonObject; 025import io.kubernetes.client.apimachinery.GroupVersionKind; 026import io.kubernetes.client.custom.Quantity; 027import io.kubernetes.client.custom.Quantity.Format; 028import io.kubernetes.client.custom.V1Patch; 029import io.kubernetes.client.openapi.ApiException; 030import io.kubernetes.client.openapi.JSON; 031import io.kubernetes.client.openapi.models.EventsV1Event; 032import java.io.IOException; 033import java.math.BigDecimal; 034import java.util.logging.Level; 035import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 036import org.jdrupes.vmoperator.common.Constants.Crd; 037import org.jdrupes.vmoperator.common.Constants.Status; 038import org.jdrupes.vmoperator.common.K8s; 039import org.jdrupes.vmoperator.common.VmDefinition; 040import org.jdrupes.vmoperator.common.VmDefinitionStub; 041import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; 042import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; 043import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; 044import org.jdrupes.vmoperator.runner.qemu.events.Exit; 045import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; 046import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; 047import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; 048import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; 049import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; 050import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; 051import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; 052import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; 053import org.jdrupes.vmoperator.util.GsonPtr; 054import org.jgrapes.core.Channel; 055import org.jgrapes.core.annotation.Handler; 056import org.jgrapes.core.events.HandlingError; 057import org.jgrapes.core.events.Start; 058 059/** 060 * Updates the CR status. 061 */ 062@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 063public class StatusUpdater extends VmDefUpdater { 064 065 @SuppressWarnings("PMD.FieldNamingConventions") 066 private static final Gson gson = new JSON().getGson(); 067 @SuppressWarnings("PMD.FieldNamingConventions") 068 private static final ObjectMapper objectMapper 069 = new ObjectMapper().registerModule(new JavaTimeModule()); 070 071 private long observedGeneration; 072 private boolean guestShutdownStops; 073 private boolean shutdownByGuest; 074 private VmDefinitionStub vmStub; 075 076 /** 077 * Instantiates a new status updater. 078 * 079 * @param componentChannel the component channel 080 */ 081 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 082 public StatusUpdater(Channel componentChannel) { 083 super(componentChannel); 084 attach(new ConsoleTracker(componentChannel)); 085 } 086 087 /** 088 * On handling error. 089 * 090 * @param event the event 091 */ 092 @Handler(channels = Channel.class) 093 public void onHandlingError(HandlingError event) { 094 if (event.throwable() instanceof ApiException exc) { 095 logger.log(Level.WARNING, exc, 096 () -> "Problem accessing kubernetes: " + exc.getResponseBody()); 097 event.stop(); 098 } 099 } 100 101 /** 102 * Handle the start event. 103 * 104 * @param event the event 105 * @throws IOException 106 * @throws ApiException 107 */ 108 @Handler 109 public void onStart(Start event) { 110 if (namespace == null) { 111 return; 112 } 113 try { 114 vmStub = VmDefinitionStub.get(apiClient, 115 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), 116 namespace, vmName); 117 var vmDef = vmStub.model().orElse(null); 118 if (vmDef == null) { 119 return; 120 } 121 observedGeneration = vmDef.getMetadata().getGeneration(); 122 vmStub.updateStatus(from -> { 123 JsonObject status = from.statusJson(); 124 status.remove(Status.LOGGED_IN_USER); 125 return status; 126 }); 127 } catch (ApiException e) { 128 logger.log(Level.SEVERE, e, 129 () -> "Cannot access VM object, terminating."); 130 event.cancel(true); 131 fire(new Exit(1)); 132 } 133 } 134 135 /** 136 * On runner configuration update. 137 * 138 * @param event the event 139 * @throws ApiException 140 */ 141 @Handler 142 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 143 public void onConfigureQemu(ConfigureQemu event) 144 throws ApiException { 145 guestShutdownStops = event.configuration().guestShutdownStops; 146 147 // Remainder applies only if we have a connection to k8s. 148 if (vmStub == null) { 149 return; 150 } 151 152 // A change of the runner configuration is typically caused 153 // by a new version of the CR. So we update only if we have 154 // a new version of the CR. There's one exception: the display 155 // password is configured by a file, not by the CR. 156 var vmDef = vmStub.model().orElse(null); 157 if (vmDef == null) { 158 return; 159 } 160 if (vmDef.metadata().getGeneration() == observedGeneration 161 && (event.configuration().hasDisplayPassword 162 || vmDef.statusJson().getAsJsonPrimitive( 163 Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) { 164 return; 165 } 166 vmStub.updateStatus(from -> { 167 JsonObject status = from.statusJson(); 168 if (!event.configuration().hasDisplayPassword) { 169 status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); 170 } 171 status.getAsJsonArray("conditions").asList().stream() 172 .map(cond -> (JsonObject) cond).filter(cond -> "Running" 173 .equals(cond.get("type").getAsString())) 174 .forEach(cond -> cond.addProperty("observedGeneration", 175 from.getMetadata().getGeneration())); 176 return status; 177 }, vmDef); 178 } 179 180 /** 181 * On runner state changed. 182 * 183 * @param event the event 184 * @throws ApiException 185 */ 186 @Handler 187 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 188 "PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" }) 189 public void onRunnerStateChanged(RunnerStateChange event) 190 throws ApiException { 191 VmDefinition vmDef; 192 if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { 193 return; 194 } 195 vmStub.updateStatus(from -> { 196 boolean running = event.runState().vmRunning(); 197 updateCondition(vmDef, "Running", running, event.reason(), 198 event.message()); 199 JsonObject status = updateCondition(vmDef, "Booted", 200 event.runState() == RunState.BOOTED, event.reason(), 201 event.message()); 202 if (event.runState() == RunState.STARTING) { 203 status.addProperty(Status.RAM, GsonPtr.to(from.data()) 204 .getAsString("spec", "vm", "maximumRam").orElse("0")); 205 status.addProperty(Status.CPUS, 1); 206 } else if (event.runState() == RunState.STOPPED) { 207 status.addProperty(Status.RAM, "0"); 208 status.addProperty(Status.CPUS, 0); 209 status.remove(Status.LOGGED_IN_USER); 210 } 211 212 if (!running) { 213 // In case console connection was still present 214 status.addProperty(Status.CONSOLE_CLIENT, ""); 215 updateCondition(from, "ConsoleConnected", false, "VmStopped", 216 "The VM is not running"); 217 218 // In case we had an irregular shutdown 219 status.remove(Status.OSINFO); 220 updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", 221 "The VM is not running"); 222 } 223 return status; 224 }, vmDef); 225 226 // Maybe stop VM 227 if (event.runState() == RunState.TERMINATING && !event.failed() 228 && guestShutdownStops && shutdownByGuest) { 229 logger.info(() -> "Stopping VM because of shutdown by guest."); 230 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 231 new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" 232 + "\", \"value\": \"Stopped\"}]"), 233 apiClient.defaultPatchOptions()); 234 if (!res.isPresent()) { 235 logger.warning( 236 () -> "Cannot patch pod annotations for: " + vmStub.name()); 237 } 238 } 239 240 // Log event 241 var evt = new EventsV1Event() 242 .reportingController(Crd.GROUP + "/" + APP_NAME) 243 .action("StatusUpdate").reason(event.reason()) 244 .note(event.message()); 245 K8s.createEvent(apiClient, vmDef, evt); 246 } 247 248 /** 249 * On ballon change. 250 * 251 * @param event the event 252 * @throws ApiException 253 */ 254 @Handler 255 public void onBallonChange(BalloonChangeEvent event) throws ApiException { 256 if (vmStub == null) { 257 return; 258 } 259 vmStub.updateStatus(from -> { 260 JsonObject status = from.statusJson(); 261 status.addProperty(Status.RAM, 262 new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) 263 .toSuffixedString()); 264 return status; 265 }); 266 } 267 268 /** 269 * On ballon change. 270 * 271 * @param event the event 272 * @throws ApiException 273 */ 274 @Handler 275 public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { 276 if (vmStub == null) { 277 return; 278 } 279 vmStub.updateStatus(from -> { 280 JsonObject status = from.statusJson(); 281 status.addProperty(Status.CPUS, event.usedCpus().size()); 282 return status; 283 }); 284 } 285 286 /** 287 * On ballon change. 288 * 289 * @param event the event 290 * @throws ApiException 291 */ 292 @Handler 293 public void onDisplayPasswordChanged(DisplayPasswordChanged event) 294 throws ApiException { 295 if (vmStub == null) { 296 return; 297 } 298 vmStub.updateStatus(from -> { 299 JsonObject status = from.statusJson(); 300 status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, 301 status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); 302 return status; 303 }); 304 } 305 306 /** 307 * On shutdown. 308 * 309 * @param event the event 310 * @throws ApiException the api exception 311 */ 312 @Handler 313 public void onShutdown(ShutdownEvent event) throws ApiException { 314 shutdownByGuest = event.byGuest(); 315 } 316 317 /** 318 * On osinfo. 319 * 320 * @param event the event 321 * @throws ApiException 322 */ 323 @Handler 324 public void onOsinfo(OsinfoEvent event) throws ApiException { 325 if (vmStub == null) { 326 return; 327 } 328 var asGson = gson.toJsonTree( 329 objectMapper.convertValue(event.osinfo(), Object.class)); 330 vmStub.updateStatus(from -> { 331 JsonObject status = from.statusJson(); 332 status.add(Status.OSINFO, asGson); 333 return status; 334 }); 335 336 } 337 338 /** 339 * @param event the event 340 * @throws ApiException 341 */ 342 @Handler 343 @SuppressWarnings("PMD.AssignmentInOperand") 344 public void onVmopAgentConnected(VmopAgentConnected event) 345 throws ApiException { 346 VmDefinition vmDef; 347 if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { 348 return; 349 } 350 vmStub.updateStatus(from -> { 351 return updateCondition(vmDef, "VmopAgentConnected", 352 true, "VmopAgentStarted", "The VM operator agent is running"); 353 }, vmDef); 354 } 355 356 /** 357 * @param event the event 358 * @throws ApiException 359 */ 360 @Handler 361 @SuppressWarnings("PMD.AssignmentInOperand") 362 public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) 363 throws ApiException { 364 vmStub.updateStatus(from -> { 365 JsonObject status = from.statusJson(); 366 status.addProperty(Status.LOGGED_IN_USER, 367 event.triggering().user()); 368 return status; 369 }); 370 } 371 372 /** 373 * @param event the event 374 * @throws ApiException 375 */ 376 @Handler 377 @SuppressWarnings("PMD.AssignmentInOperand") 378 public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) 379 throws ApiException { 380 vmStub.updateStatus(from -> { 381 JsonObject status = from.statusJson(); 382 status.remove(Status.LOGGED_IN_USER); 383 return status; 384 }); 385 } 386}