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