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