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