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.fasterxml.jackson.core.JsonProcessingException; 022import com.fasterxml.jackson.databind.DeserializationFeature; 023import com.fasterxml.jackson.databind.JsonMappingException; 024import com.fasterxml.jackson.databind.JsonNode; 025import com.fasterxml.jackson.databind.ObjectMapper; 026import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; 027import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; 028import freemarker.core.ParseException; 029import freemarker.template.MalformedTemplateNameException; 030import freemarker.template.TemplateException; 031import freemarker.template.TemplateExceptionHandler; 032import freemarker.template.TemplateNotFoundException; 033import java.io.File; 034import java.io.FileDescriptor; 035import java.io.IOException; 036import java.io.InputStream; 037import java.io.StringWriter; 038import java.lang.reflect.UndeclaredThrowableException; 039import java.nio.file.Files; 040import java.nio.file.Path; 041import java.nio.file.Paths; 042import java.time.Instant; 043import java.util.Comparator; 044import java.util.EnumSet; 045import java.util.HashMap; 046import java.util.Optional; 047import java.util.Set; 048import java.util.logging.Level; 049import java.util.logging.LogManager; 050import java.util.logging.Logger; 051import java.util.stream.Collectors; 052import java.util.stream.StreamSupport; 053import org.apache.commons.cli.CommandLine; 054import org.apache.commons.cli.CommandLineParser; 055import org.apache.commons.cli.DefaultParser; 056import org.apache.commons.cli.Option; 057import org.apache.commons.cli.Options; 058import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 059import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; 060import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; 061import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; 062import org.jdrupes.vmoperator.runner.qemu.events.Exit; 063import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; 064import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; 065import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; 066import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; 067import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; 068import org.jdrupes.vmoperator.util.FsdUtils; 069import org.jgrapes.core.Channel; 070import org.jgrapes.core.Component; 071import org.jgrapes.core.Components; 072import org.jgrapes.core.EventPipeline; 073import org.jgrapes.core.TypedIdKey; 074import org.jgrapes.core.annotation.Handler; 075import org.jgrapes.core.events.HandlingError; 076import org.jgrapes.core.events.Start; 077import org.jgrapes.core.events.Started; 078import org.jgrapes.core.events.Stop; 079import org.jgrapes.core.internal.EventProcessor; 080import org.jgrapes.io.NioDispatcher; 081import org.jgrapes.io.events.Input; 082import org.jgrapes.io.events.ProcessExited; 083import org.jgrapes.io.events.ProcessStarted; 084import org.jgrapes.io.events.StartProcess; 085import org.jgrapes.io.process.ProcessManager; 086import org.jgrapes.io.process.ProcessManager.ProcessChannel; 087import org.jgrapes.io.util.LineCollector; 088import org.jgrapes.net.SocketConnector; 089import org.jgrapes.util.FileSystemWatcher; 090import org.jgrapes.util.YamlConfigurationStore; 091import org.jgrapes.util.events.ConfigurationUpdate; 092import org.jgrapes.util.events.FileChanged; 093import org.jgrapes.util.events.FileChanged.Kind; 094import org.jgrapes.util.events.InitialConfiguration; 095import org.jgrapes.util.events.WatchFile; 096 097/** 098 * The Runner is responsible for managing the Qemu process and 099 * optionally a process that emulates a TPM (software TPM). It's 100 * main function is best described by the following state diagram. 101 * 102 * ![Runner state diagram](RunnerStates.svg) 103 * 104 * The {@link Runner} associates an {@link EventProcessor} with the 105 * {@link Start} event. This "runner event processor" must be used 106 * for all events related to the application level function. Components 107 * that handle events from other sources (and thus event processors) 108 * must fire any resulting events on the runner event processor in order 109 * to maintain synchronization. 110 * 111 * @startuml RunnerStates.svg 112 * [*] --> Initializing 113 * Initializing -> Initializing: InitialConfiguration/configure Runner 114 * Initializing -> Initializing: Start/start Runner 115 * 116 * state "Starting (Processes)" as StartingProcess { 117 * 118 * state "Start qemu" as qemu 119 * state "Open monitor" as monitor 120 * state "Configure QMP" as waitForConfigured 121 * state "Configure QEMU" as configure 122 * state success <<exitPoint>> 123 * state error <<exitPoint>> 124 * 125 * state prepFork <<fork>> 126 * state prepJoin <<join>> 127 * state "Generate cloud-init image" as cloudInit 128 * prepFork --> cloudInit: [cloud-init data provided] 129 * swtpm --> prepJoin: FileChanged[swtpm socket created] 130 * state "Start swtpm" as swtpm 131 * prepFork --> swtpm: [use swtpm] 132 * swtpm: entry/start swtpm 133 * cloudInit --> prepJoin: ProcessExited 134 * cloudInit: entry/generate cloud-init image 135 * prepFork --> prepJoin: [else] 136 * 137 * prepJoin --> qemu 138 * 139 * qemu: entry/start qemu 140 * qemu --> monitor : FileChanged[monitor socket created] 141 * 142 * monitor: entry/fire OpenSocketConnection 143 * monitor --> waitForConfigured: ClientConnected[for monitor] 144 * monitor -> error: ConnectError[for monitor] 145 * 146 * waitForConfigured: entry/fire QmpCapabilities 147 * waitForConfigured --> configure: QmpConfigured 148 * 149 * configure: entry/fire ConfigureQemu 150 * configure --> success: ConfigureQemu (last handler)/fire cont command 151 * } 152 * 153 * Initializing --> prepFork: Started 154 * 155 * success --> Running 156 * 157 * state Terminating { 158 * state terminate <<entryPoint>> 159 * state qemuRunning <<choice>> 160 * state terminated <<exitPoint>> 161 * state "Powerdown qemu" as qemuPowerdown 162 * state "Await process termination" as terminateProcesses 163 * 164 * terminate --> qemuRunning 165 * qemuRunning --> qemuPowerdown:[qemu monitor open] 166 * qemuRunning --> terminateProcesses:[else] 167 * 168 * qemuPowerdown: entry/suspend Stop, send powerdown to qemu, start timer 169 * 170 * qemuPowerdown --> terminateProcesses: Closed[for monitor]/resume Stop,\ncancel Timer 171 * qemuPowerdown --> terminateProcesses: Timeout/resume Stop 172 * terminateProcesses --> terminated 173 * } 174 * 175 * Running --> terminate: Stop 176 * Running --> terminate: ProcessExited[process qemu] 177 * error --> terminate 178 * StartingProcess --> terminate: ProcessExited 179 * 180 * state Stopped { 181 * state stopped <<entryPoint>> 182 * 183 * stopped --> [*] 184 * } 185 * 186 * terminated --> stopped 187 * 188 * @enduml 189 * 190 */ 191@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", 192 "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods", 193 "PMD.CouplingBetweenObjects" }) 194public class Runner extends Component { 195 196 private static final String QEMU = "qemu"; 197 private static final String SWTPM = "swtpm"; 198 private static final String CLOUD_INIT_IMG = "cloudInitImg"; 199 private static final String TEMPLATE_DIR 200 = "/opt/" + APP_NAME.replace("-", "") + "/templates"; 201 private static final String DEFAULT_TEMPLATE 202 = "Standard-VM-latest.ftl.yaml"; 203 private static final String SAVED_TEMPLATE = "VM.ftl.yaml"; 204 private static final String FW_VARS = "fw-vars.fd"; 205 private static int exitStatus; 206 207 private final EventPipeline rep = newEventPipeline(); 208 private final ObjectMapper yamlMapper = new ObjectMapper(YAMLFactory 209 .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) 210 .build()); 211 private final JsonNode defaults; 212 @SuppressWarnings("PMD.UseConcurrentHashMap") 213 private final File configFile; 214 private final Path configDir; 215 private Configuration config = new Configuration(); 216 private final freemarker.template.Configuration fmConfig; 217 private CommandDefinition swtpmDefinition; 218 private CommandDefinition cloudInitImgDefinition; 219 private CommandDefinition qemuDefinition; 220 private final QemuMonitor qemuMonitor; 221 private Integer resetCounter; 222 private RunState state = RunState.INITIALIZING; 223 224 /** Preparatory actions for QEMU start */ 225 @SuppressWarnings("PMD.FieldNamingConventions") 226 private enum QemuPreps { 227 Config, 228 Tpm, 229 CloudInit 230 } 231 232 private final Set<QemuPreps> qemuLatch = EnumSet.noneOf(QemuPreps.class); 233 234 /** 235 * Instantiates a new runner. 236 * 237 * @param cmdLine the cmd line 238 * @throws IOException Signals that an I/O exception has occurred. 239 */ 240 @SuppressWarnings({ "PMD.SystemPrintln", 241 "PMD.ConstructorCallsOverridableMethod" }) 242 public Runner(CommandLine cmdLine) throws IOException { 243 yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, 244 false); 245 246 // Get defaults 247 defaults = yamlMapper.readValue( 248 Runner.class.getResourceAsStream("defaults.yaml"), JsonNode.class); 249 250 // Get the config 251 configFile = new File(cmdLine.getOptionValue('c', 252 "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); 253 // Don't rely on night config to produce a good exception 254 // for this simple case 255 if (!Files.isReadable(configFile.toPath())) { 256 throw new IOException( 257 "Cannot read configuration file " + configFile); 258 } 259 configDir = configFile.getParentFile().toPath().toRealPath(); 260 261 // Configure freemarker library 262 fmConfig = new freemarker.template.Configuration( 263 freemarker.template.Configuration.VERSION_2_3_32); 264 fmConfig.setDirectoryForTemplateLoading(new File("/")); 265 fmConfig.setDefaultEncoding("utf-8"); 266 fmConfig.setObjectWrapper(new ExtendedObjectWrapper( 267 fmConfig.getIncompatibleImprovements())); 268 fmConfig.setTemplateExceptionHandler( 269 TemplateExceptionHandler.RETHROW_HANDLER); 270 fmConfig.setLogTemplateExceptions(false); 271 272 // Prepare component tree 273 attach(new NioDispatcher()); 274 attach(new FileSystemWatcher(channel())); 275 attach(new ProcessManager(channel())); 276 attach(new SocketConnector(channel())); 277 attach(qemuMonitor = new QemuMonitor(channel(), configDir)); 278 attach(new StatusUpdater(channel())); 279 attach(new YamlConfigurationStore(channel(), configFile, false)); 280 fire(new WatchFile(configFile.toPath())); 281 } 282 283 /** 284 * Log the exception when a handling error is reported. 285 * 286 * @param event the event 287 */ 288 @Handler(channels = Channel.class, priority = -10_000) 289 @SuppressWarnings("PMD.GuardLogStatement") 290 public void onHandlingError(HandlingError event) { 291 logger.log(Level.WARNING, event.throwable(), 292 () -> "Problem invoking handler with " + event.event() + ": " 293 + event.message()); 294 event.stop(); 295 } 296 297 /** 298 * On configuration update. 299 * 300 * @param event the event 301 */ 302 @Handler 303 public void onConfigurationUpdate(ConfigurationUpdate event) { 304 event.structured(componentPath()).ifPresent(c -> { 305 var newConf = yamlMapper.convertValue(c, Configuration.class); 306 307 // Add some values from other sources to configuration 308 newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); 309 Path dsPath 310 = configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE); 311 newConf.hasDisplayPassword = dsPath.toFile().canRead(); 312 313 // Special actions for initial configuration (startup) 314 if (event instanceof InitialConfiguration) { 315 processInitialConfiguration(newConf); 316 return; 317 } 318 logger.fine(() -> "Updating configuration"); 319 rep.fire(new ConfigureQemu(newConf, state)); 320 }); 321 } 322 323 @SuppressWarnings("PMD.LambdaCanBeMethodReference") 324 private void processInitialConfiguration(Configuration newConfig) { 325 try { 326 config = newConfig; 327 if (!config.check()) { 328 // Invalid configuration, not used, problems already logged. 329 config = null; 330 } 331 332 // Prepare firmware files and add to config 333 setFirmwarePaths(); 334 335 // Obtain more context data from template 336 var tplData = dataFromTemplate(); 337 swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM)) 338 .map(d -> new CommandDefinition(SWTPM, d)).orElse(null); 339 logger.finest(() -> swtpmDefinition.toString()); 340 qemuDefinition = Optional.ofNullable(tplData.get(QEMU)) 341 .map(d -> new CommandDefinition(QEMU, d)).orElse(null); 342 logger.finest(() -> qemuDefinition.toString()); 343 cloudInitImgDefinition 344 = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) 345 .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) 346 .orElse(null); 347 logger.finest(() -> cloudInitImgDefinition.toString()); 348 349 // Forward some values to child components 350 qemuMonitor.configure(config.monitorSocket, 351 config.vm.powerdownTimeout); 352 } catch (IllegalArgumentException | IOException | TemplateException e) { 353 logger.log(Level.SEVERE, e, () -> "Invalid configuration: " 354 + e.getMessage()); 355 // Don't use default configuration 356 config = null; 357 } 358 } 359 360 @SuppressWarnings({ "PMD.CognitiveComplexity", 361 "PMD.DataflowAnomalyAnalysis" }) 362 private void setFirmwarePaths() throws IOException { 363 JsonNode firmware = defaults.path("firmware").path(config.vm.firmware); 364 // Get file for firmware ROM 365 JsonNode codePaths = firmware.path("rom"); 366 for (var p : codePaths) { 367 var path = Path.of(p.asText()); 368 if (Files.exists(path)) { 369 config.firmwareRom = path; 370 break; 371 } 372 } 373 if (codePaths.iterator().hasNext() && config.firmwareRom == null) { 374 throw new IllegalArgumentException("No ROM found, candidates were: " 375 + StreamSupport.stream(codePaths.spliterator(), false) 376 .map(JsonNode::asText).collect(Collectors.joining(", "))); 377 } 378 379 // Get file for firmware vars, if necessary 380 config.firmwareVars = config.dataDir.resolve(FW_VARS); 381 if (!Files.exists(config.firmwareVars)) { 382 for (var p : firmware.path("vars")) { 383 var path = Path.of(p.asText()); 384 if (Files.exists(path)) { 385 Files.copy(path, config.firmwareVars); 386 break; 387 } 388 } 389 } 390 } 391 392 private JsonNode dataFromTemplate() 393 throws IOException, TemplateNotFoundException, 394 MalformedTemplateNameException, ParseException, TemplateException, 395 JsonProcessingException, JsonMappingException { 396 // Try saved template, copy if not there (or to be updated) 397 Path templatePath = config.dataDir.resolve(SAVED_TEMPLATE); 398 if (!Files.isReadable(templatePath) || config.updateTemplate) { 399 // Get template 400 Path sourcePath = Paths.get(TEMPLATE_DIR).resolve(Optional 401 .ofNullable(config.template).orElse(DEFAULT_TEMPLATE)); 402 Files.deleteIfExists(templatePath); 403 Files.copy(sourcePath, templatePath); 404 logger.fine(() -> "Using template " + sourcePath); 405 } else { 406 logger.fine(() -> "Using saved template."); 407 } 408 409 // Configure data model 410 var model = new HashMap<String, Object>(); 411 model.put("dataDir", config.dataDir); 412 model.put("runtimeDir", config.runtimeDir); 413 model.put("firmwareRom", Optional.ofNullable(config.firmwareRom) 414 .map(Object::toString).orElse(null)); 415 model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) 416 .map(Object::toString).orElse(null)); 417 model.put("hasDisplayPassword", config.hasDisplayPassword); 418 model.put("cloudInit", config.cloudInit); 419 model.put("vm", config.vm); 420 logger.finest(() -> "Processing template with model: " + model); 421 422 // Combine template and data and parse result 423 // (tempting, but no need to use a pipe here) 424 var fmTemplate = fmConfig.getTemplate(templatePath.toString()); 425 StringWriter out = new StringWriter(); 426 fmTemplate.process(model, out); 427 logger.finest(() -> "Result of processing template: " + out); 428 return yamlMapper.readValue(out.toString(), JsonNode.class); 429 } 430 431 /** 432 * Handle the start event. 433 * 434 * @param event the event 435 */ 436 @Handler(priority = 100) 437 public void onStart(Start event) { 438 if (config == null) { 439 // Missing configuration, fail 440 event.cancel(true); 441 fire(new Stop()); 442 return; 443 } 444 445 // Make sure to use thread specific client 446 // https://github.com/kubernetes-client/java/issues/100 447 io.kubernetes.client.openapi.Configuration.setDefaultApiClient(null); 448 449 // Provide specific event pipeline to avoid concurrency. 450 event.setAssociated(EventPipeline.class, rep); 451 try { 452 // Store process id 453 try (var pidFile = Files.newBufferedWriter( 454 config.runtimeDir.resolve("runner.pid"))) { 455 pidFile.write(ProcessHandle.current().pid() + "\n"); 456 } 457 458 // Files to watch for 459 Files.deleteIfExists(config.swtpmSocket); 460 fire(new WatchFile(config.swtpmSocket)); 461 462 // Helper files 463 var ticket = Optional.ofNullable(config.vm.display) 464 .map(d -> d.spice).map(s -> s.ticket); 465 if (ticket.isPresent()) { 466 Files.write(config.runtimeDir.resolve("ticket.txt"), 467 ticket.get().getBytes()); 468 } 469 } catch (IOException e) { 470 logger.log(Level.SEVERE, e, 471 () -> "Cannot start runner: " + e.getMessage()); 472 fire(new Stop()); 473 } 474 } 475 476 /** 477 * Handle the started event. 478 * 479 * @param event the event 480 */ 481 @Handler 482 public void onStarted(Started event) { 483 state = RunState.STARTING; 484 rep.fire(new RunnerStateChange(state, "RunnerStarted", 485 "Runner has been started")); 486 // Start first process(es) 487 qemuLatch.add(QemuPreps.Config); 488 if (config.vm.useTpm && swtpmDefinition != null) { 489 startProcess(swtpmDefinition); 490 qemuLatch.add(QemuPreps.Tpm); 491 } 492 if (config.cloudInit != null) { 493 generateCloudInitImg(); 494 qemuLatch.add(QemuPreps.CloudInit); 495 } 496 mayBeStartQemu(QemuPreps.Config); 497 } 498 499 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 500 private void mayBeStartQemu(QemuPreps done) { 501 synchronized (qemuLatch) { 502 if (qemuLatch.isEmpty()) { 503 return; 504 } 505 qemuLatch.remove(done); 506 if (qemuLatch.isEmpty()) { 507 startProcess(qemuDefinition); 508 } 509 } 510 } 511 512 private void generateCloudInitImg() { 513 try { 514 var cloudInitDir = config.dataDir.resolve("cloud-init"); 515 cloudInitDir.toFile().mkdir(); 516 try (var metaOut 517 = Files.newBufferedWriter(cloudInitDir.resolve("meta-data"))) { 518 if (config.cloudInit.metaData != null) { 519 yamlMapper.writer().writeValue(metaOut, 520 config.cloudInit.metaData); 521 } 522 } 523 try (var userOut 524 = Files.newBufferedWriter(cloudInitDir.resolve("user-data"))) { 525 userOut.write("#cloud-config\n"); 526 if (config.cloudInit.userData != null) { 527 yamlMapper.writer().writeValue(userOut, 528 config.cloudInit.userData); 529 } 530 } 531 if (config.cloudInit.networkConfig != null) { 532 try (var networkConfig = Files.newBufferedWriter( 533 cloudInitDir.resolve("network-config"))) { 534 yamlMapper.writer().writeValue(networkConfig, 535 config.cloudInit.networkConfig); 536 } 537 } 538 startProcess(cloudInitImgDefinition); 539 } catch (IOException e) { 540 logger.log(Level.SEVERE, e, 541 () -> "Cannot start runner: " + e.getMessage()); 542 fire(new Stop()); 543 } 544 } 545 546 private boolean startProcess(CommandDefinition toStart) { 547 logger.info( 548 () -> "Starting process: " + String.join(" ", toStart.command)); 549 fire(new StartProcess(toStart.command) 550 .setAssociated(CommandDefinition.class, toStart)); 551 return true; 552 } 553 554 /** 555 * Watch for the creation of the swtpm socket and start the 556 * qemu process if it has been created. 557 * 558 * @param event the event 559 */ 560 @Handler 561 public void onFileChanged(FileChanged event) { 562 if (event.change() == Kind.CREATED 563 && event.path().equals(config.swtpmSocket)) { 564 // swtpm running, maybe start qemu 565 mayBeStartQemu(QemuPreps.Tpm); 566 } 567 } 568 569 /** 570 * Associate required data with the process channel and register the 571 * channel in the context. 572 * 573 * @param event the event 574 * @param channel the channel 575 * @throws InterruptedException the interrupted exception 576 */ 577 @Handler 578 @SuppressWarnings({ "PMD.SwitchStmtsShouldHaveDefault", 579 "PMD.TooFewBranchesForASwitchStatement" }) 580 public void onProcessStarted(ProcessStarted event, ProcessChannel channel) 581 throws InterruptedException { 582 event.startEvent().associated(CommandDefinition.class) 583 .ifPresent(procDef -> { 584 channel.setAssociated(CommandDefinition.class, procDef); 585 try (var pidFile = Files.newBufferedWriter( 586 config.runtimeDir.resolve(procDef.name + ".pid"))) { 587 pidFile.write(channel.process().toHandle().pid() + "\n"); 588 } catch (IOException e) { 589 throw new UndeclaredThrowableException(e); 590 } 591 592 // Associate the channel with a line collector (one for 593 // each stream) for logging the process's output. 594 TypedIdKey.associate(channel, 1, 595 new LineCollector().nativeCharset() 596 .consumer(line -> logger 597 .info(() -> procDef.name() + "(out): " + line))); 598 TypedIdKey.associate(channel, 2, 599 new LineCollector().nativeCharset() 600 .consumer(line -> logger 601 .info(() -> procDef.name() + "(err): " + line))); 602 }); 603 } 604 605 /** 606 * Forward output from the processes to to the log. 607 * 608 * @param event the event 609 * @param channel the channel 610 */ 611 @Handler 612 public void onInput(Input<?> event, ProcessChannel channel) { 613 event.associated(FileDescriptor.class, Integer.class).ifPresent( 614 fd -> TypedIdKey.associated(channel, LineCollector.class, fd) 615 .ifPresent(lc -> lc.feed(event))); 616 } 617 618 /** 619 * On monitor ready. 620 * 621 * @param event the event 622 */ 623 @Handler 624 public void onQmpConfigured(QmpConfigured event) { 625 rep.fire(new ConfigureQemu(config, state)); 626 } 627 628 /** 629 * On configure qemu. 630 * 631 * @param event the event 632 */ 633 @Handler(priority = -1000) 634 public void onConfigureQemuFinal(ConfigureQemu event) { 635 if (state == RunState.STARTING) { 636 fire(new MonitorCommand(new QmpCont())); 637 state = RunState.RUNNING; 638 rep.fire(new RunnerStateChange(state, "VmStarted", 639 "Qemu has been configured and is continuing")); 640 } 641 } 642 643 /** 644 * On configure qemu. 645 * 646 * @param event the event 647 */ 648 @Handler 649 public void onConfigureQemu(ConfigureQemu event) { 650 if (state == RunState.RUNNING) { 651 if (resetCounter != null 652 && event.configuration().resetCounter != null 653 && event.configuration().resetCounter > resetCounter) { 654 fire(new MonitorCommand(new QmpReset())); 655 } 656 resetCounter = event.configuration().resetCounter; 657 } 658 } 659 660 /** 661 * On process exited. 662 * 663 * @param event the event 664 * @param channel the channel 665 */ 666 @Handler 667 public void onProcessExited(ProcessExited event, ProcessChannel channel) { 668 channel.associated(CommandDefinition.class).ifPresent(procDef -> { 669 if (procDef.equals(cloudInitImgDefinition) 670 && event.exitValue() == 0) { 671 // Cloud-init ISO generation was successful. 672 mayBeStartQemu(QemuPreps.CloudInit); 673 return; 674 } 675 // No other process(es) may exit during startup 676 if (state == RunState.STARTING) { 677 logger.severe(() -> "Process " + procDef.name 678 + " has exited with value " + event.exitValue() 679 + " during startup."); 680 rep.fire(new Stop()); 681 return; 682 } 683 if (procDef.equals(qemuDefinition) && state == RunState.RUNNING) { 684 rep.fire(new Exit(event.exitValue())); 685 } 686 logger.info(() -> "Process " + procDef.name 687 + " has exited with value " + event.exitValue()); 688 }); 689 } 690 691 /** 692 * On exit. 693 * 694 * @param event the event 695 */ 696 @Handler(priority = 10_001) 697 public void onExit(Exit event) { 698 if (exitStatus == 0) { 699 exitStatus = event.exitStatus(); 700 } 701 } 702 703 /** 704 * On stop. 705 * 706 * @param event the event 707 */ 708 @Handler(priority = 10_000) 709 public void onStopFirst(Stop event) { 710 state = RunState.TERMINATING; 711 rep.fire(new RunnerStateChange(state, "VmTerminating", 712 "The VM is being shut down", exitStatus != 0)); 713 } 714 715 /** 716 * On stop. 717 * 718 * @param event the event 719 */ 720 @Handler(priority = -10_000) 721 public void onStopLast(Stop event) { 722 state = RunState.STOPPED; 723 rep.fire(new RunnerStateChange(state, "VmStopped", 724 "The VM has been shut down")); 725 } 726 727 @SuppressWarnings("PMD.ConfusingArgumentToVarargsMethod") 728 private void shutdown() { 729 if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { 730 fire(new Stop()); 731 } 732 try { 733 Components.awaitExhaustion(); 734 } catch (InterruptedException e) { 735 logger.log(Level.WARNING, e, () -> "Proper shutdown failed."); 736 } 737 738 Optional.ofNullable(config).map(c -> c.runtimeDir) 739 .ifPresent(runtimeDir -> { 740 try { 741 Files.walk(runtimeDir).sorted(Comparator.reverseOrder()) 742 .map(Path::toFile).forEach(File::delete); 743 } catch (IOException e) { 744 logger.warning(() -> String.format( 745 "Cannot delete runtime directory \"%s\".", 746 runtimeDir)); 747 } 748 }); 749 } 750 751 static { 752 try { 753 InputStream props; 754 var path = FsdUtils.findConfigFile(APP_NAME.replace("-", ""), 755 "logging.properties"); 756 if (path.isPresent()) { 757 props = Files.newInputStream(path.get()); 758 } else { 759 props = Runner.class.getResourceAsStream("logging.properties"); 760 } 761 LogManager.getLogManager().readConfiguration(props); 762 Logger.getLogger(Runner.class.getName()).log(Level.CONFIG, 763 () -> path.isPresent() 764 ? "Using logging configuration from " + path.get() 765 : "Using default logging configuration"); 766 } catch (IOException e) { 767 e.printStackTrace(); 768 } 769 } 770 771 /** 772 * The main method. 773 * 774 * @param args the command 775 */ 776 public static void main(String[] args) { 777 // The Runner is the root component 778 try { 779 var logger = Logger.getLogger(Runner.class.getName()); 780 logger.fine(() -> "Version: " 781 + Runner.class.getPackage().getImplementationVersion()); 782 logger.fine(() -> "running on " + System.getProperty("java.vm.name") 783 + " (" + System.getProperty("java.vm.version") + ")" 784 + " from " + System.getProperty("java.vm.vendor")); 785 CommandLineParser parser = new DefaultParser(); 786 // parse the command line arguments 787 final Options options = new Options(); 788 options.addOption(new Option("c", "config", true, "The configu" 789 + "ration file (defaults to /etc/opt/vmrunner/config.yaml).")); 790 CommandLine cmd = parser.parse(options, args); 791 var app = new Runner(cmd); 792 793 // Prepare Stop 794 Runtime.getRuntime().addShutdownHook(new Thread(() -> { 795 app.shutdown(); 796 })); 797 798 // Start the application 799 Components.start(app); 800 801 // Wait for (regular) termination 802 Components.awaitExhaustion(); 803 System.exit(exitStatus); 804 805 } catch (IOException | InterruptedException 806 | org.apache.commons.cli.ParseException e) { 807 Logger.getLogger(Runner.class.getName()).log(Level.SEVERE, e, 808 () -> "Failed to start runner: " + e.getMessage()); 809 } 810 } 811}