diff --git a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml index 461e79b56..34c15114c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml @@ -27,6 +27,14 @@ # be set when starting the runner during development e.g. from the IDE. # "namespace": ... + # Defines data for generating a cloud-init ISO image that is + # attached to the VM. + # "cloudInit": + # "metaData": + # ... + # "userData": + # ... + # Define the VM (required) "vm": # The VM's name (required) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index dda438b9d..ff60176ca 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -24,6 +24,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.logging.Level; @@ -65,10 +66,23 @@ public class Configuration implements Dto { /** The firmware vars. */ public Path firmwareVars; + /** Optional cloud-init data. */ + public CloudInit cloudInit; + /** The vm. */ @SuppressWarnings("PMD.ShortVariable") public Vm vm; + /** + * Subsection "cloud-init". + */ + public static class CloudInit implements Dto { + @SuppressWarnings("PMD.UseConcurrentHashMap") + public Map metaData; + @SuppressWarnings("PMD.UseConcurrentHashMap") + public Map userData; + } + /** * Subsection "vm". */ diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine index def82efb1..b87049ed1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine @@ -2,7 +2,7 @@ FROM docker.io/alpine RUN apk update -RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk17 +RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk17 mtools RUN mkdir -p /etc/qemu && echo "allow all" > /etc/qemu/bridge.conf diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch index e3055c0d5..2ccb2f931 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch @@ -6,6 +6,7 @@ RUN pacman-key --init \ && pacman -Sy --noconfirm archlinux-keyring && pacman -Su --noconfirm \ && pacman -S --noconfirm which qemu-full virtiofsd \ edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \ + mtools \ && pacman -Scc --noconfirm # Remove all targets. diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 979ac8e7b..fe3784f64 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; import freemarker.template.TemplateException; @@ -40,6 +41,7 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -178,9 +180,12 @@ * */ @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", - "PMD.DataflowAnomalyAnalysis" }) + "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" }) public class Runner extends Component { + private static final String QEMU = "qemu"; + private static final String SWTPM = "swtpm"; + private static final String CLOUD_INIT_IMG = "cloudInitImg"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -190,16 +195,29 @@ public class Runner extends Component { private static int exitStatus; private EventPipeline rep; - private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + private final ObjectMapper yamlMapper = new ObjectMapper(YAMLFactory + .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build()); private final JsonNode defaults; @SuppressWarnings("PMD.UseConcurrentHashMap") private Configuration config = new Configuration(); private final freemarker.template.Configuration fmConfig; private CommandDefinition swtpmDefinition; + private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; private State state = State.INITIALIZING; + /** Preparatory actions for QEMU start */ + @SuppressWarnings("PMD.FieldNamingConventions") + private enum QemuPreps { + Config, + Tpm, + CloudInit + } + + private final Set qemuLatch = new HashSet<>(); + /** * Instantiates a new runner. * @@ -293,10 +311,14 @@ private void processInitialConfiguration( // Obtain more context data from template var tplData = dataFromTemplate(); - swtpmDefinition = Optional.ofNullable(tplData.get("swtpm")) - .map(d -> new CommandDefinition("swtpm", d)).orElse(null); - qemuDefinition = Optional.ofNullable(tplData.get("qemu")) - .map(d -> new CommandDefinition("qemu", d)).orElse(null); + swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM)) + .map(d -> new CommandDefinition(SWTPM, d)).orElse(null); + qemuDefinition = Optional.ofNullable(tplData.get(QEMU)) + .map(d -> new CommandDefinition(QEMU, d)).orElse(null); + cloudInitImgDefinition + = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) + .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) + .orElse(null); // Forward some values to child components qemuMonitor.configure(config.monitorSocket, @@ -360,6 +382,7 @@ private JsonNode dataFromTemplate() .map(Object::toString).orElse(null)); model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) .map(Object::toString).orElse(null)); + model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); if (Optional.ofNullable(config.vm.display) .map(d -> d.spice).map(s -> s.ticket).isPresent()) { @@ -430,12 +453,56 @@ public void onStarted(Started event) { state = State.STARTING; rep.fire(new RunnerStateChange(state, "RunnerStarted", "Runner has been started")); - // Start first process + // Start first process(es) + qemuLatch.add(QemuPreps.Config); if (config.vm.useTpm && swtpmDefinition != null) { startProcess(swtpmDefinition); - return; + qemuLatch.add(QemuPreps.Tpm); + } + if (config.cloudInit != null) { + generateCloudInitImg(); + qemuLatch.add(QemuPreps.CloudInit); + } + mayBeStartQemu(QemuPreps.Config); + } + + private void mayBeStartQemu(QemuPreps done) { + synchronized (qemuLatch) { + if (qemuLatch.isEmpty()) { + return; + } + qemuLatch.remove(done); + if (qemuLatch.isEmpty()) { + startProcess(qemuDefinition); + } + } + } + + private void generateCloudInitImg() { + try { + var cloudInitDir = config.dataDir.resolve("cloud-init"); + cloudInitDir.toFile().mkdir(); + var metaOut + = Files.newBufferedWriter(cloudInitDir.resolve("meta-data")); + if (config.cloudInit.metaData != null) { + yamlMapper.writer().writeValue(metaOut, + config.cloudInit.metaData); + } + metaOut.close(); + var userOut + = Files.newBufferedWriter(cloudInitDir.resolve("user-data")); + userOut.write("#cloud-config\n"); + if (config.cloudInit.userData != null) { + yamlMapper.writer().writeValue(userOut, + config.cloudInit.userData); + } + userOut.close(); + startProcess(cloudInitImgDefinition); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot start runner: " + e.getMessage()); + fire(new Stop()); } - startProcess(qemuDefinition); } private boolean startProcess(CommandDefinition toStart) { @@ -456,8 +523,8 @@ private boolean startProcess(CommandDefinition toStart) { public void onFileChanged(FileChanged event) { if (event.change() == Kind.CREATED && event.path().equals(config.swtpmSocket)) { - // swtpm running, start qemu - startProcess(qemuDefinition); + // swtpm running, maybe start qemu + mayBeStartQemu(QemuPreps.Tpm); return; } } @@ -545,7 +612,13 @@ public void onConfigureQemu(RunnerConfigurationUpdate event) { @Handler public void onProcessExited(ProcessExited event, ProcessChannel channel) { channel.associated(CommandDefinition.class).ifPresent(procDef -> { - // No process(es) may exit during startup + if (procDef.equals(cloudInitImgDefinition) + && event.exitValue() == 0) { + // Cloud-init ISO generation was successful. + mayBeStartQemu(QemuPreps.CloudInit); + return; + } + // No other process(es) may exit during startup if (state == State.STARTING) { logger.severe(() -> "Process " + procDef.name + " has exited with value " + event.exitValue() diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index b21db8fc1..dff656abe 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -11,6 +11,19 @@ - [ "--ctrl", "type=unixio,path=${ runtimeDir }/swtpm-sock,mode=0600" ] - "--terminate" +"cloudInitImg": + # Candidate paths for the executable + "executable": [ "/bin/sh", "/usr/bin/sh" ] + + # Arguments may be specified as nested lists for better readability. + # The arguments are flattened before being passed to the process. + "arguments": + - "-c" + - >- + mformat -C -f 1440 -v CIDATA -i ${ runtimeDir }/cloud-init.img + && mcopy -i ${ runtimeDir }/cloud-init.img + ${ dataDir }/cloud-init/meta-data ${ dataDir }/cloud-init/user-data :: + "qemu": # Candidate paths for the executable "executable": [ "/usr/bin/qemu-system-x86_64" ] @@ -183,6 +196,16 @@ <#break> + # Cloud-init image + <#if cloudInit??> + - [ "-blockdev", "node-name=drive-${ drvCounter }-host-resource,\ + driver=file,filename=${ runtimeDir }/cloud-init.img" ] + # - how to use the file (as sequence of literal blocks) + - [ "-blockdev", "node-name=drive-${ drvCounter }-backend,driver=raw,\ + file=drive-${ drvCounter }-host-resource" ] + # - the driver (what the guest sees) + - [ "-device", "virtio-blk-pci,drive=drive-${ drvCounter }-backend" ] + <#if vm.display??> <#if vm.display.spice??>