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