Add cloud-init support in runner.

This commit is contained in:
Michael Lipp 2024-02-17 14:37:12 +01:00
parent b5622a459c
commit 24f762d28c
6 changed files with 132 additions and 13 deletions

View file

@ -27,6 +27,14 @@
# be set when starting the runner during development e.g. from the IDE. # be set when starting the runner during development e.g. from the IDE.
# "namespace": ... # "namespace": ...
# Defines data for generating a cloud-init ISO image that is
# attached to the VM.
# "cloudInit":
# "metaData":
# ...
# "userData":
# ...
# Define the VM (required) # Define the VM (required)
"vm": "vm":
# The VM's name (required) # The VM's name (required)

View file

@ -24,6 +24,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermission;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
@ -65,10 +66,23 @@ public class Configuration implements Dto {
/** The firmware vars. */ /** The firmware vars. */
public Path firmwareVars; public Path firmwareVars;
/** Optional cloud-init data. */
public CloudInit cloudInit;
/** The vm. */ /** The vm. */
@SuppressWarnings("PMD.ShortVariable") @SuppressWarnings("PMD.ShortVariable")
public Vm vm; public Vm vm;
/**
* Subsection "cloud-init".
*/
public static class CloudInit implements Dto {
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> metaData;
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> userData;
}
/** /**
* Subsection "vm". * Subsection "vm".
*/ */

View file

@ -2,7 +2,7 @@ FROM docker.io/alpine
RUN apk update 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 RUN mkdir -p /etc/qemu && echo "allow all" > /etc/qemu/bridge.conf

View file

@ -6,6 +6,7 @@ RUN pacman-key --init \
&& pacman -Sy --noconfirm archlinux-keyring && pacman -Su --noconfirm \ && pacman -Sy --noconfirm archlinux-keyring && pacman -Su --noconfirm \
&& pacman -S --noconfirm which qemu-full virtiofsd \ && pacman -S --noconfirm which qemu-full virtiofsd \
edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \ edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \
mtools \
&& pacman -Scc --noconfirm && pacman -Scc --noconfirm
# Remove all targets. # Remove all targets.

View file

@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
import freemarker.core.ParseException; import freemarker.core.ParseException;
import freemarker.template.MalformedTemplateNameException; import freemarker.template.MalformedTemplateNameException;
import freemarker.template.TemplateException; import freemarker.template.TemplateException;
@ -40,6 +41,7 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@ -178,9 +180,12 @@ import org.jgrapes.util.events.WatchFile;
* *
*/ */
@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace",
"PMD.DataflowAnomalyAnalysis" }) "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" })
public class Runner extends Component { 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 private static final String TEMPLATE_DIR
= "/opt/" + APP_NAME.replace("-", "") + "/templates"; = "/opt/" + APP_NAME.replace("-", "") + "/templates";
private static final String DEFAULT_TEMPLATE private static final String DEFAULT_TEMPLATE
@ -190,16 +195,29 @@ public class Runner extends Component {
private static int exitStatus; private static int exitStatus;
private EventPipeline rep; 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; private final JsonNode defaults;
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
private Configuration config = new Configuration(); private Configuration config = new Configuration();
private final freemarker.template.Configuration fmConfig; private final freemarker.template.Configuration fmConfig;
private CommandDefinition swtpmDefinition; private CommandDefinition swtpmDefinition;
private CommandDefinition cloudInitImgDefinition;
private CommandDefinition qemuDefinition; private CommandDefinition qemuDefinition;
private final QemuMonitor qemuMonitor; private final QemuMonitor qemuMonitor;
private State state = State.INITIALIZING; private State state = State.INITIALIZING;
/** Preparatory actions for QEMU start */
@SuppressWarnings("PMD.FieldNamingConventions")
private enum QemuPreps {
Config,
Tpm,
CloudInit
}
private final Set<QemuPreps> qemuLatch = new HashSet<>();
/** /**
* Instantiates a new runner. * Instantiates a new runner.
* *
@ -293,10 +311,14 @@ public class Runner extends Component {
// Obtain more context data from template // Obtain more context data from template
var tplData = dataFromTemplate(); var tplData = dataFromTemplate();
swtpmDefinition = Optional.ofNullable(tplData.get("swtpm")) swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM))
.map(d -> new CommandDefinition("swtpm", d)).orElse(null); .map(d -> new CommandDefinition(SWTPM, d)).orElse(null);
qemuDefinition = Optional.ofNullable(tplData.get("qemu")) qemuDefinition = Optional.ofNullable(tplData.get(QEMU))
.map(d -> new CommandDefinition("qemu", d)).orElse(null); .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 // Forward some values to child components
qemuMonitor.configure(config.monitorSocket, qemuMonitor.configure(config.monitorSocket,
@ -360,6 +382,7 @@ public class Runner extends Component {
.map(Object::toString).orElse(null)); .map(Object::toString).orElse(null));
model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) model.put("firmwareVars", Optional.ofNullable(config.firmwareVars)
.map(Object::toString).orElse(null)); .map(Object::toString).orElse(null));
model.put("cloudInit", config.cloudInit);
model.put("vm", config.vm); model.put("vm", config.vm);
if (Optional.ofNullable(config.vm.display) if (Optional.ofNullable(config.vm.display)
.map(d -> d.spice).map(s -> s.ticket).isPresent()) { .map(d -> d.spice).map(s -> s.ticket).isPresent()) {
@ -430,12 +453,56 @@ public class Runner extends Component {
state = State.STARTING; state = State.STARTING;
rep.fire(new RunnerStateChange(state, "RunnerStarted", rep.fire(new RunnerStateChange(state, "RunnerStarted",
"Runner has been started")); "Runner has been started"));
// Start first process // Start first process(es)
qemuLatch.add(QemuPreps.Config);
if (config.vm.useTpm && swtpmDefinition != null) { if (config.vm.useTpm && swtpmDefinition != null) {
startProcess(swtpmDefinition); 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) { private boolean startProcess(CommandDefinition toStart) {
@ -456,8 +523,8 @@ public class Runner extends Component {
public void onFileChanged(FileChanged event) { public void onFileChanged(FileChanged event) {
if (event.change() == Kind.CREATED if (event.change() == Kind.CREATED
&& event.path().equals(config.swtpmSocket)) { && event.path().equals(config.swtpmSocket)) {
// swtpm running, start qemu // swtpm running, maybe start qemu
startProcess(qemuDefinition); mayBeStartQemu(QemuPreps.Tpm);
return; return;
} }
} }
@ -545,7 +612,13 @@ public class Runner extends Component {
@Handler @Handler
public void onProcessExited(ProcessExited event, ProcessChannel channel) { public void onProcessExited(ProcessExited event, ProcessChannel channel) {
channel.associated(CommandDefinition.class).ifPresent(procDef -> { 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) { if (state == State.STARTING) {
logger.severe(() -> "Process " + procDef.name logger.severe(() -> "Process " + procDef.name
+ " has exited with value " + event.exitValue() + " has exited with value " + event.exitValue()

View file

@ -11,6 +11,19 @@
- [ "--ctrl", "type=unixio,path=${ runtimeDir }/swtpm-sock,mode=0600" ] - [ "--ctrl", "type=unixio,path=${ runtimeDir }/swtpm-sock,mode=0600" ]
- "--terminate" - "--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": "qemu":
# Candidate paths for the executable # Candidate paths for the executable
"executable": [ "/usr/bin/qemu-system-x86_64" ] "executable": [ "/usr/bin/qemu-system-x86_64" ]
@ -183,6 +196,16 @@
<#break> <#break>
</#switch> </#switch>
</#list> </#list>
# 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>
<#if vm.display??> <#if vm.display??>
<#if vm.display.spice??> <#if vm.display.spice??>