001/* 002 * VM-Operator 003 * Copyright (C) 2023,2024 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.google.gson.JsonObject; 022import io.kubernetes.client.apimachinery.GroupVersionKind; 023import io.kubernetes.client.custom.Quantity; 024import io.kubernetes.client.custom.Quantity.Format; 025import io.kubernetes.client.custom.V1Patch; 026import io.kubernetes.client.openapi.ApiException; 027import io.kubernetes.client.openapi.models.EventsV1Event; 028import java.io.IOException; 029import java.math.BigDecimal; 030import java.util.Set; 031import java.util.logging.Level; 032import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 033import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; 034import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; 035import org.jdrupes.vmoperator.common.K8s; 036import org.jdrupes.vmoperator.common.VmDefinitionModel; 037import org.jdrupes.vmoperator.common.VmDefinitionStub; 038import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; 039import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; 040import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; 041import org.jdrupes.vmoperator.runner.qemu.events.Exit; 042import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; 043import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; 044import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; 045import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; 046import org.jdrupes.vmoperator.util.GsonPtr; 047import org.jgrapes.core.Channel; 048import org.jgrapes.core.annotation.Handler; 049import org.jgrapes.core.events.HandlingError; 050import org.jgrapes.core.events.Start; 051 052/** 053 * Updates the CR status. 054 */ 055@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 056public class StatusUpdater extends VmDefUpdater { 057 058 private static final Set<RunState> RUNNING_STATES 059 = Set.of(RunState.RUNNING, RunState.TERMINATING); 060 061 private long observedGeneration; 062 private boolean guestShutdownStops; 063 private boolean shutdownByGuest; 064 private VmDefinitionStub vmStub; 065 066 /** 067 * Instantiates a new status updater. 068 * 069 * @param componentChannel the component channel 070 */ 071 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 072 public StatusUpdater(Channel componentChannel) { 073 super(componentChannel); 074 attach(new ConsoleTracker(componentChannel)); 075 } 076 077 /** 078 * On handling error. 079 * 080 * @param event the event 081 */ 082 @Handler(channels = Channel.class) 083 public void onHandlingError(HandlingError event) { 084 if (event.throwable() instanceof ApiException exc) { 085 logger.log(Level.WARNING, exc, 086 () -> "Problem accessing kubernetes: " + exc.getResponseBody()); 087 event.stop(); 088 } 089 } 090 091 /** 092 * Handle the start event. 093 * 094 * @param event the event 095 * @throws IOException 096 * @throws ApiException 097 */ 098 @Handler 099 public void onStart(Start event) { 100 if (namespace == null) { 101 return; 102 } 103 try { 104 vmStub = VmDefinitionStub.get(apiClient, 105 new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), 106 namespace, vmName); 107 vmStub.model().ifPresent(model -> { 108 observedGeneration = model.getMetadata().getGeneration(); 109 }); 110 } catch (ApiException e) { 111 logger.log(Level.SEVERE, e, 112 () -> "Cannot access VM object, terminating."); 113 event.cancel(true); 114 fire(new Exit(1)); 115 } 116 } 117 118 /** 119 * On runner configuration update. 120 * 121 * @param event the event 122 * @throws ApiException 123 */ 124 @Handler 125 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 126 public void onConfigureQemu(ConfigureQemu event) 127 throws ApiException { 128 guestShutdownStops = event.configuration().guestShutdownStops; 129 130 // Remainder applies only if we have a connection to k8s. 131 if (vmStub == null) { 132 return; 133 } 134 135 // A change of the runner configuration is typically caused 136 // by a new version of the CR. So we update only if we have 137 // a new version of the CR. There's one exception: the display 138 // password is configured by a file, not by the CR. 139 var vmDef = vmStub.model(); 140 if (vmDef.isPresent() 141 && vmDef.get().metadata().getGeneration() == observedGeneration 142 && (event.configuration().hasDisplayPassword 143 || vmDef.get().status().getAsJsonPrimitive( 144 "displayPasswordSerial").getAsInt() == -1)) { 145 return; 146 } 147 vmStub.updateStatus(vmDef.get(), from -> { 148 JsonObject status = from.status(); 149 if (!event.configuration().hasDisplayPassword) { 150 status.addProperty("displayPasswordSerial", -1); 151 } 152 status.getAsJsonArray("conditions").asList().stream() 153 .map(cond -> (JsonObject) cond).filter(cond -> "Running" 154 .equals(cond.get("type").getAsString())) 155 .forEach(cond -> cond.addProperty("observedGeneration", 156 from.getMetadata().getGeneration())); 157 return status; 158 }); 159 } 160 161 /** 162 * On runner state changed. 163 * 164 * @param event the event 165 * @throws ApiException 166 */ 167 @Handler 168 @SuppressWarnings({ "PMD.AssignmentInOperand", 169 "PMD.AvoidLiteralsInIfCondition" }) 170 public void onRunnerStateChanged(RunnerStateChange event) 171 throws ApiException { 172 VmDefinitionModel vmDef; 173 if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { 174 return; 175 } 176 vmStub.updateStatus(vmDef, from -> { 177 JsonObject status = from.status(); 178 boolean running = RUNNING_STATES.contains(event.runState()); 179 updateCondition(vmDef, vmDef.status(), "Running", running, 180 event.reason(), event.message()); 181 if (event.runState() == RunState.STARTING) { 182 status.addProperty("ram", GsonPtr.to(from.data()) 183 .getAsString("spec", "vm", "maximumRam").orElse("0")); 184 status.addProperty("cpus", 1); 185 } else if (event.runState() == RunState.STOPPED) { 186 status.addProperty("ram", "0"); 187 status.addProperty("cpus", 0); 188 } 189 190 // In case console connection was still present 191 if (!running) { 192 status.addProperty("consoleClient", ""); 193 updateCondition(from, status, "ConsoleConnected", false, 194 "VmStopped", "The VM has been shut down"); 195 } 196 return status; 197 }); 198 199 // Maybe stop VM 200 if (event.runState() == RunState.TERMINATING && !event.failed() 201 && guestShutdownStops && shutdownByGuest) { 202 logger.info(() -> "Stopping VM because of shutdown by guest."); 203 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 204 new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" 205 + "\", \"value\": \"Stopped\"}]"), 206 apiClient.defaultPatchOptions()); 207 if (!res.isPresent()) { 208 logger.warning( 209 () -> "Cannot patch pod annotations for: " + vmStub.name()); 210 } 211 } 212 213 // Log event 214 var evt = new EventsV1Event() 215 .reportingController(VM_OP_GROUP + "/" + APP_NAME) 216 .action("StatusUpdate").reason(event.reason()) 217 .note(event.message()); 218 K8s.createEvent(apiClient, vmDef, evt); 219 } 220 221 /** 222 * On ballon change. 223 * 224 * @param event the event 225 * @throws ApiException 226 */ 227 @Handler 228 public void onBallonChange(BalloonChangeEvent event) throws ApiException { 229 if (vmStub == null) { 230 return; 231 } 232 vmStub.updateStatus(from -> { 233 JsonObject status = from.status(); 234 status.addProperty("ram", 235 new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) 236 .toSuffixedString()); 237 return status; 238 }); 239 } 240 241 /** 242 * On ballon change. 243 * 244 * @param event the event 245 * @throws ApiException 246 */ 247 @Handler 248 public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { 249 if (vmStub == null) { 250 return; 251 } 252 vmStub.updateStatus(from -> { 253 JsonObject status = from.status(); 254 status.addProperty("cpus", event.usedCpus().size()); 255 return status; 256 }); 257 } 258 259 /** 260 * On ballon change. 261 * 262 * @param event the event 263 * @throws ApiException 264 */ 265 @Handler 266 public void onDisplayPasswordChanged(DisplayPasswordChanged event) 267 throws ApiException { 268 if (vmStub == null) { 269 return; 270 } 271 vmStub.updateStatus(from -> { 272 JsonObject status = from.status(); 273 status.addProperty("displayPasswordSerial", 274 status.get("displayPasswordSerial").getAsLong() + 1); 275 return status; 276 }); 277 } 278 279 /** 280 * On shutdown. 281 * 282 * @param event the event 283 * @throws ApiException the api exception 284 */ 285 @Handler 286 public void onShutdown(ShutdownEvent event) throws ApiException { 287 shutdownByGuest = event.byGuest(); 288 } 289}