Support for display secrets (#21)
Some checks failed
Java CI with Gradle / build (push) Has been cancelled

This commit is contained in:
Michael N. Lipp 2024-03-20 11:03:09 +01:00 committed by GitHub
parent 85b0a160f3
commit 3103452170
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2081 additions and 658 deletions

View file

@ -0,0 +1 @@
test-vm

View file

@ -25,8 +25,8 @@ import java.util.concurrent.ConcurrentHashMap;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpChangeMedium;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpOpenTray;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpRemoveMedium;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State;
import org.jdrupes.vmoperator.runner.qemu.events.TrayMovedEvent;
import org.jgrapes.core.Channel;
@ -68,7 +68,7 @@ public class CdMediaController extends Component {
@Handler
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.AvoidInstantiatingObjectsInLoops" })
public void onConfigureQemu(RunnerConfigurationUpdate event) {
public void onConfigureQemu(ConfigureQemu event) {
if (event.state() == State.TERMINATING) {
return;
}

View file

@ -46,7 +46,7 @@ public class Configuration implements Dto {
@SuppressWarnings("PMD.FieldNamingConventions")
protected final Logger logger = Logger.getLogger(getClass().getName());
/** Configuration timestamp */
/** Configuration timestamp. */
public Instant asOf;
/** The data dir. */
@ -73,6 +73,9 @@ public class Configuration implements Dto {
/** The firmware vars. */
public Path firmwareVars;
/** The display password. */
public boolean hasDisplayPassword;
/** Optional cloud-init data. */
public CloudInit cloudInit;
@ -87,10 +90,16 @@ public class Configuration implements Dto {
* Subsection "cloud-init".
*/
public static class CloudInit implements Dto {
/** The meta data. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> metaData;
/** The user data. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> userData;
/** The network config. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> networkConfig;
}
@ -230,6 +239,8 @@ public class Configuration implements Dto {
* The Class Display.
*/
public static class Display implements Dto {
/** The spice. */
public Spice spice;
}

View file

@ -27,11 +27,11 @@ import java.util.Set;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpAddCpu;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.CpuAdded;
import org.jdrupes.vmoperator.runner.qemu.events.CpuDeleted;
import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
@ -45,7 +45,7 @@ public class CpuController extends Component {
private Integer currentCpus;
private Integer desiredCpus;
private RunnerConfigurationUpdate suspendedConfigure;
private ConfigureQemu suspendedConfigure;
/**
* Instantiates a new CPU controller.
@ -62,7 +62,7 @@ public class CpuController extends Component {
* @param event the event
*/
@Handler
public void onConfigureQemu(RunnerConfigurationUpdate event) {
public void onConfigureQemu(ConfigureQemu event) {
if (event.state() == State.TERMINATING) {
return;
}

View file

@ -0,0 +1,117 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.logging.Level;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.FileChanged;
import org.jgrapes.util.events.WatchFile;
/**
* The Class DisplayController.
*/
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class DisplayController extends Component {
public static final String DISPLAY_PASSWORD_FILE = "display-password";
private String currentPassword;
private String protocol;
private final Path configDir;
/**
* Instantiates a new Display controller.
*
* @param componentChannel the component channel
* @param configDir
*/
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
public DisplayController(Channel componentChannel, Path configDir) {
super(componentChannel);
this.configDir = configDir;
fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE)));
}
/**
* On configure qemu.
*
* @param event the event
*/
@Handler
public void onConfigureQemu(ConfigureQemu event) {
if (event.state() == State.TERMINATING) {
return;
}
protocol
= event.configuration().vm.display.spice != null ? "spice" : null;
updatePassword();
}
/**
* Watch for changes of the password file.
*
* @param event the event
*/
@Handler
@SuppressWarnings("PMD.EmptyCatchBlock")
public void onFileChanged(FileChanged event) {
if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) {
updatePassword();
}
}
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
private void updatePassword() {
if (protocol == null) {
return;
}
String password;
Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE);
if (dpPath.toFile().canRead()) {
logger.finer(() -> "Found display password");
try {
password = Files.readString(dpPath);
} catch (IOException e) {
logger.log(Level.WARNING, e, () -> "Cannot read display"
+ " password: " + e.getMessage());
return;
}
} else {
logger.finer(() -> "No display password");
return;
}
if (Objects.equals(this.currentPassword, password)) {
return;
}
logger.fine(() -> "Updating display password");
fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password)));
}
}

View file

@ -35,12 +35,12 @@ import java.util.logging.Level;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorEvent;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult;
import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.Components;
@ -87,13 +87,16 @@ public class QemuMonitor extends Component {
* Instantiates a new qemu monitor.
*
* @param componentChannel the component channel
* @param configDir the config dir
* @throws IOException Signals that an I/O exception has occurred.
*/
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
public QemuMonitor(Channel componentChannel) throws IOException {
public QemuMonitor(Channel componentChannel, Path configDir)
throws IOException {
super(componentChannel);
attach(new RamController(channel()));
attach(new CpuController(channel()));
attach(new DisplayController(channel(), configDir));
attach(new CdMediaController(channel()));
}
@ -254,17 +257,18 @@ public class QemuMonitor extends Component {
* @param event the event
*/
@Handler
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
public void onExecQmpCommand(MonitorCommand event) {
var command = event.command();
logger.fine(() -> "monitor(out): " + command.toString());
String asText;
try {
asText = mapper.writeValueAsString(command.toJson());
asText = command.asText();
} catch (JsonProcessingException e) {
logger.log(Level.SEVERE, e,
() -> "Cannot serialize Json: " + e.getMessage());
return;
}
logger.fine(() -> "monitor(out): " + asText);
synchronized (executing) {
monitorChannel.associated(Writer.class).ifPresent(writer -> {
try {
@ -343,7 +347,7 @@ public class QemuMonitor extends Component {
* @param event the event
*/
@Handler
public void onConfigureQemu(RunnerConfigurationUpdate event) {
public void onConfigureQemu(ConfigureQemu event) {
int newTimeout = event.configuration().vm.powerdownTimeout;
if (powerdownTimeout != newTimeout) {
powerdownTimeout = newTimeout;

View file

@ -21,8 +21,8 @@ package org.jdrupes.vmoperator.runner.qemu;
import java.math.BigInteger;
import java.util.Optional;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetBalloon;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.annotation.Handler;
@ -50,7 +50,7 @@ public class RamController extends Component {
* @param event the event
*/
@Handler
public void onConfigureQemu(RunnerConfigurationUpdate event) {
public void onConfigureQemu(ConfigureQemu event) {
Optional.ofNullable(event.configuration().vm.currentRam)
.ifPresent(cr -> {
if (currentRam != null && currentRam.equals(cr)) {

View file

@ -1,6 +1,6 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
* Copyright (C) 2023,2024 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@ -55,10 +55,10 @@ import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State;
import org.jdrupes.vmoperator.util.ExtendedObjectWrapper;
@ -143,8 +143,8 @@ import org.jgrapes.util.events.WatchFile;
* waitForConfigured: entry/fire QmpCapabilities
* waitForConfigured --> configure: QmpConfigured
*
* configure: entry/fire RunnerConfigurationUpdate
* configure --> success: RunnerConfigurationUpdate (last handler)/fire cont command
* configure: entry/fire ConfigureQemu
* configure --> success: ConfigureQemu (last handler)/fire cont command
* }
*
* Initializing --> prepFork: Started
@ -207,6 +207,7 @@ public class Runner extends Component {
private final JsonNode defaults;
@SuppressWarnings("PMD.UseConcurrentHashMap")
private final File configFile;
private final Path configDir;
private Configuration config = new Configuration();
private final freemarker.template.Configuration fmConfig;
private CommandDefinition swtpmDefinition;
@ -240,6 +241,17 @@ public class Runner extends Component {
defaults = yamlMapper.readValue(
Runner.class.getResourceAsStream("defaults.yaml"), JsonNode.class);
// Get the config
configFile = new File(cmdLine.getOptionValue('c',
"/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml"));
// Don't rely on night config to produce a good exception
// for this simple case
if (!Files.isReadable(configFile.toPath())) {
throw new IOException(
"Cannot read configuration file " + configFile);
}
configDir = configFile.getParentFile().toPath().toRealPath();
// Configure freemarker library
fmConfig = new freemarker.template.Configuration(
freemarker.template.Configuration.VERSION_2_3_32);
@ -256,17 +268,8 @@ public class Runner extends Component {
attach(new FileSystemWatcher(channel()));
attach(new ProcessManager(channel()));
attach(new SocketConnector(channel()));
attach(qemuMonitor = new QemuMonitor(channel()));
attach(qemuMonitor = new QemuMonitor(channel(), configDir));
attach(new StatusUpdater(channel()));
configFile = new File(cmdLine.getOptionValue('c',
"/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml"));
// Don't rely on night config to produce a good exception
// for this simple case
if (!Files.isReadable(configFile.toPath())) {
throw new IOException(
"Cannot read configuration file " + configFile);
}
attach(new YamlConfigurationStore(channel(), configFile, false));
fire(new WatchFile(configFile.toPath()));
}
@ -294,13 +297,20 @@ public class Runner extends Component {
public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured(componentPath()).ifPresent(c -> {
var newConf = yamlMapper.convertValue(c, Configuration.class);
// Add some values from other sources to configuration
newConf.asOf = Instant.ofEpochSecond(configFile.lastModified());
Path dsPath
= configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE);
newConf.hasDisplayPassword = dsPath.toFile().canRead();
// Special actions for initial configuration (startup)
if (event instanceof InitialConfiguration) {
processInitialConfiguration(newConf);
return;
}
logger.fine(() -> "Updating configuration");
rep.fire(new RunnerConfigurationUpdate(newConf, state));
rep.fire(new ConfigureQemu(newConf, state));
});
}
@ -388,12 +398,9 @@ public class Runner extends Component {
.map(Object::toString).orElse(null));
model.put("firmwareVars", Optional.ofNullable(config.firmwareVars)
.map(Object::toString).orElse(null));
model.put("hasDisplayPassword", config.hasDisplayPassword);
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()) {
model.put("ticketPath", config.runtimeDir.resolve("ticket.txt"));
}
// Combine template and data and parse result
// (tempting, but no need to use a pipe here)
@ -598,7 +605,7 @@ public class Runner extends Component {
*/
@Handler
public void onQmpConfigured(QmpConfigured event) {
rep.fire(new RunnerConfigurationUpdate(config, state));
rep.fire(new ConfigureQemu(config, state));
}
/**
@ -607,7 +614,7 @@ public class Runner extends Component {
* @param event the event
*/
@Handler(priority = -1000)
public void onConfigureQemu(RunnerConfigurationUpdate event) {
public void onConfigureQemu(ConfigureQemu event) {
if (state == State.STARTING) {
fire(new MonitorCommand(new QmpCont()));
state = State.RUNNING;

View file

@ -42,9 +42,9 @@ import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State;
import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
@ -178,7 +178,7 @@ public class StatusUpdater extends Component {
* @throws ApiException
*/
@Handler
public void onRunnerConfigurationUpdate(RunnerConfigurationUpdate event)
public void onConfigureQemu(ConfigureQemu event)
throws ApiException {
guestShutdownStops = event.configuration().guestShutdownStops;

View file

@ -18,6 +18,7 @@
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
@ -55,4 +56,30 @@ public abstract class QmpCommand {
* @return the json node
*/
public abstract JsonNode toJson();
/**
* Returns the string representation.
*
* @return the string
* @throws JsonProcessingException the JSON processing exception
*/
public String asText() throws JsonProcessingException {
return mapper.writeValueAsString(toJson());
}
/**
* Calls {@link #asText()} but suppresses the
* {@link JsonProcessingException}.
*
* @return the string
*/
@Override
public String toString() {
try {
return asText();
} catch (JsonProcessingException e) {
return "(no string representation)";
}
}
}

View file

@ -0,0 +1,68 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.commands;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
/**
* A {@link QmpCommand} that sets the display password.
*/
public class QmpSetDisplayPassword extends QmpCommand {
private final String password;
private final String protocol;
/**
* Instantiates a new command.
*
* @param protocol the protocol
* @param password the password
*/
public QmpSetDisplayPassword(String protocol, String password) {
this.protocol = protocol;
this.password = password;
}
@Override
public JsonNode toJson() {
ObjectNode cmd = mapper.createObjectNode();
cmd.put("execute", "set_password");
ObjectNode args = mapper.createObjectNode();
cmd.set("arguments", args);
args.set("protocol", new TextNode(protocol));
args.set("password", new TextNode(password));
return cmd;
}
@Override
public String toString() {
try {
var json = toJson();
((ObjectNode) json.get("arguments")).set("password",
new TextNode("********"));
return mapper.writeValueAsString(json);
} catch (JsonProcessingException e) {
return "(no string representation)";
}
}
}

View file

@ -31,7 +31,7 @@ import org.jgrapes.core.Event;
* on the event and only {@link Event#resumeHandling() resume handling}
* when the adaption has completed.
*/
public class RunnerConfigurationUpdate extends Event<Void> {
public class ConfigureQemu extends Event<Void> {
private final Configuration configuration;
private final State state;
@ -41,7 +41,7 @@ public class RunnerConfigurationUpdate extends Event<Void> {
*
* @param channels the channels
*/
public RunnerConfigurationUpdate(Configuration configuration, State state,
public ConfigureQemu(Configuration configuration, State state,
Channel... channels) {
super(channels);
this.state = state;

View file

@ -215,12 +215,8 @@
<#assign spice = vm.display.spice/>
# SPICE (display, channels ...)
# https://www.linux-kvm.org/page/SPICE
<#if ticketPath??>
- [ "-object", "secret,id=spiceTicket,file=${ ticketPath }" ]
</#if>
- [ "-spice", "port=${ spice.port?c }\
<#if spice.ticket??>,password-secret=spiceTicket\
<#else>,disable-ticketing=on</#if>\
,disable-ticketing=<#if hasDisplayPassword!false>off<#else>on</#if>\
<#if spice.streamingVideo??>,streaming-video=${ spice.streamingVideo }</#if>\
,seamless-migration=on" ]
- [ "-chardev", "spicevmc,id=vdagentdev,name=vdagent" ]