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