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.nio.file.Files; 031import java.nio.file.Path; 032import java.time.Instant; 033import java.util.Map; 034import java.util.Optional; 035import java.util.Set; 036import java.util.logging.Level; 037import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 038import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; 039import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; 040import org.jdrupes.vmoperator.common.K8s; 041import org.jdrupes.vmoperator.common.K8sClient; 042import org.jdrupes.vmoperator.common.K8sDynamicModel; 043import org.jdrupes.vmoperator.common.VmDefinitionModel; 044import org.jdrupes.vmoperator.common.VmDefinitionStub; 045import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; 046import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; 047import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; 048import org.jdrupes.vmoperator.runner.qemu.events.Exit; 049import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; 050import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; 051import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; 052import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; 053import org.jdrupes.vmoperator.util.GsonPtr; 054import org.jgrapes.core.Channel; 055import org.jgrapes.core.Component; 056import org.jgrapes.core.annotation.Handler; 057import org.jgrapes.core.events.HandlingError; 058import org.jgrapes.core.events.Start; 059import org.jgrapes.util.events.ConfigurationUpdate; 060import org.jgrapes.util.events.InitialConfiguration; 061 062/** 063 * Updates the CR status. 064 */ 065@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 066public class StatusUpdater extends Component { 067 068 private static final Set<RunState> RUNNING_STATES 069 = Set.of(RunState.RUNNING, RunState.TERMINATING); 070 071 private String namespace; 072 private String vmName; 073 private K8sClient apiClient; 074 private long observedGeneration; 075 private boolean guestShutdownStops; 076 private boolean shutdownByGuest; 077 private VmDefinitionStub vmStub; 078 079 /** 080 * Instantiates a new status updater. 081 * 082 * @param componentChannel the component channel 083 */ 084 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 085 public StatusUpdater(Channel componentChannel) { 086 super(componentChannel); 087 try { 088 apiClient = new K8sClient(); 089 io.kubernetes.client.openapi.Configuration 090 .setDefaultApiClient(apiClient); 091 } catch (IOException e) { 092 logger.log(Level.SEVERE, e, 093 () -> "Cannot access events API, terminating."); 094 fire(new Exit(1)); 095 } 096 } 097 098 /** 099 * On handling error. 100 * 101 * @param event the event 102 */ 103 @Handler(channels = Channel.class) 104 public void onHandlingError(HandlingError event) { 105 if (event.throwable() instanceof ApiException exc) { 106 logger.log(Level.WARNING, exc, 107 () -> "Problem accessing kubernetes: " + exc.getResponseBody()); 108 event.stop(); 109 } 110 } 111 112 /** 113 * On configuration update. 114 * 115 * @param event the event 116 */ 117 @Handler 118 @SuppressWarnings("unchecked") 119 public void onConfigurationUpdate(ConfigurationUpdate event) { 120 event.structured("/Runner").ifPresent(c -> { 121 if (event instanceof InitialConfiguration) { 122 namespace = (String) c.get("namespace"); 123 updateNamespace(); 124 vmName = Optional.ofNullable((Map<String, String>) c.get("vm")) 125 .map(vm -> vm.get("name")).orElse(null); 126 } 127 }); 128 } 129 130 private void updateNamespace() { 131 if (namespace == null) { 132 var path = Path 133 .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); 134 if (Files.isReadable(path)) { 135 try { 136 namespace = Files.lines(path).findFirst().orElse(null); 137 } catch (IOException e) { 138 logger.log(Level.WARNING, e, 139 () -> "Cannot read namespace."); 140 } 141 } 142 } 143 if (namespace == null) { 144 logger.warning(() -> "Namespace is unknown, some functions" 145 + " won't be available."); 146 } 147 } 148 149 /** 150 * Handle the start event. 151 * 152 * @param event the event 153 * @throws IOException 154 * @throws ApiException 155 */ 156 @Handler 157 public void onStart(Start event) { 158 if (namespace == null) { 159 return; 160 } 161 try { 162 vmStub = VmDefinitionStub.get(apiClient, 163 new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), 164 namespace, vmName); 165 vmStub.model().ifPresent(model -> { 166 observedGeneration = model.getMetadata().getGeneration(); 167 }); 168 } catch (ApiException e) { 169 logger.log(Level.SEVERE, e, 170 () -> "Cannot access VM object, terminating."); 171 event.cancel(true); 172 fire(new Exit(1)); 173 } 174 } 175 176 /** 177 * On runner configuration update. 178 * 179 * @param event the event 180 * @throws ApiException 181 */ 182 @Handler 183 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 184 public void onConfigureQemu(ConfigureQemu event) 185 throws ApiException { 186 guestShutdownStops = event.configuration().guestShutdownStops; 187 188 // Remainder applies only if we have a connection to k8s. 189 if (vmStub == null) { 190 return; 191 } 192 193 // A change of the runner configuration is typically caused 194 // by a new version of the CR. So we update only if we have 195 // a new version of the CR. There's one exception: the display 196 // password is configured by a file, not by the CR. 197 var vmDef = vmStub.model(); 198 if (vmDef.isPresent() 199 && vmDef.get().metadata().getGeneration() == observedGeneration 200 && (event.configuration().hasDisplayPassword 201 || vmDef.get().status().getAsJsonPrimitive( 202 "displayPasswordSerial").getAsInt() == -1)) { 203 return; 204 } 205 vmStub.updateStatus(vmDef.get(), from -> { 206 JsonObject status = from.status(); 207 if (!event.configuration().hasDisplayPassword) { 208 status.addProperty("displayPasswordSerial", -1); 209 } 210 status.getAsJsonArray("conditions").asList().stream() 211 .map(cond -> (JsonObject) cond).filter(cond -> "Running" 212 .equals(cond.get("type").getAsString())) 213 .forEach(cond -> cond.addProperty("observedGeneration", 214 from.getMetadata().getGeneration())); 215 return status; 216 }); 217 } 218 219 /** 220 * On runner state changed. 221 * 222 * @param event the event 223 * @throws ApiException 224 */ 225 @Handler 226 @SuppressWarnings({ "PMD.AssignmentInOperand", 227 "PMD.AvoidLiteralsInIfCondition" }) 228 public void onRunnerStateChanged(RunnerStateChange event) 229 throws ApiException { 230 VmDefinitionModel vmDef; 231 if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { 232 return; 233 } 234 vmStub.updateStatus(vmDef, from -> { 235 JsonObject status = from.status(); 236 status.getAsJsonArray("conditions").asList().stream() 237 .map(cond -> (JsonObject) cond) 238 .forEach(cond -> { 239 if ("Running".equals(cond.get("type").getAsString())) { 240 updateRunningCondition(event, from, cond); 241 } 242 }); 243 if (event.runState() == RunState.STARTING) { 244 status.addProperty("ram", GsonPtr.to(from.data()) 245 .getAsString("spec", "vm", "maximumRam").orElse("0")); 246 status.addProperty("cpus", 1); 247 } else if (event.runState() == RunState.STOPPED) { 248 status.addProperty("ram", "0"); 249 status.addProperty("cpus", 0); 250 } 251 return status; 252 }); 253 254 // Maybe stop VM 255 if (event.runState() == RunState.TERMINATING && !event.failed() 256 && guestShutdownStops && shutdownByGuest) { 257 logger.info(() -> "Stopping VM because of shutdown by guest."); 258 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 259 new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" 260 + "\", \"value\": \"Stopped\"}]"), 261 apiClient.defaultPatchOptions()); 262 if (!res.isPresent()) { 263 logger.warning( 264 () -> "Cannot patch pod annotations for: " + vmStub.name()); 265 } 266 } 267 268 // Log event 269 var evt = new EventsV1Event() 270 .reportingController(VM_OP_GROUP + "/" + APP_NAME) 271 .action("StatusUpdate").reason(event.reason()) 272 .note(event.message()); 273 K8s.createEvent(apiClient, vmDef, evt); 274 } 275 276 private void updateRunningCondition(RunnerStateChange event, 277 K8sDynamicModel from, JsonObject cond) { 278 boolean reportedRunning 279 = "True".equals(cond.get("status").getAsString()); 280 if (RUNNING_STATES.contains(event.runState()) 281 && !reportedRunning) { 282 cond.addProperty("status", "True"); 283 cond.addProperty("lastTransitionTime", 284 Instant.now().toString()); 285 } 286 if (!RUNNING_STATES.contains(event.runState()) 287 && reportedRunning) { 288 cond.addProperty("status", "False"); 289 cond.addProperty("lastTransitionTime", 290 Instant.now().toString()); 291 } 292 cond.addProperty("reason", event.reason()); 293 cond.addProperty("message", event.message()); 294 cond.addProperty("observedGeneration", 295 from.getMetadata().getGeneration()); 296 } 297 298 /** 299 * On ballon change. 300 * 301 * @param event the event 302 * @throws ApiException 303 */ 304 @Handler 305 public void onBallonChange(BalloonChangeEvent event) throws ApiException { 306 if (vmStub == null) { 307 return; 308 } 309 vmStub.updateStatus(from -> { 310 JsonObject status = from.status(); 311 status.addProperty("ram", 312 new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) 313 .toSuffixedString()); 314 return status; 315 }); 316 } 317 318 /** 319 * On ballon change. 320 * 321 * @param event the event 322 * @throws ApiException 323 */ 324 @Handler 325 public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { 326 if (vmStub == null) { 327 return; 328 } 329 vmStub.updateStatus(from -> { 330 JsonObject status = from.status(); 331 status.addProperty("cpus", event.usedCpus().size()); 332 return status; 333 }); 334 } 335 336 /** 337 * On ballon change. 338 * 339 * @param event the event 340 * @throws ApiException 341 */ 342 @Handler 343 public void onDisplayPasswordChanged(DisplayPasswordChanged event) 344 throws ApiException { 345 if (vmStub == null) { 346 return; 347 } 348 vmStub.updateStatus(from -> { 349 JsonObject status = from.status(); 350 status.addProperty("displayPasswordSerial", 351 status.get("displayPasswordSerial").getAsLong() + 1); 352 return status; 353 }); 354 } 355 356 /** 357 * On shutdown. 358 * 359 * @param event the event 360 * @throws ApiException the api exception 361 */ 362 @Handler 363 public void onShutdown(ShutdownEvent event) throws ApiException { 364 shutdownByGuest = event.byGuest(); 365 } 366}