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