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