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