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