Make VM extra data a class.

This commit is contained in:
Michael Lipp 2025-02-02 13:54:10 +01:00
parent d5e589709f
commit d27339b1e9
7 changed files with 208 additions and 123 deletions

View file

@ -24,9 +24,6 @@ import com.google.gson.Gson;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.JSON;
import io.kubernetes.client.openapi.models.V1Condition; import io.kubernetes.client.openapi.models.V1Condition;
import io.kubernetes.client.util.Strings;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
@ -38,9 +35,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function; import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
@ -52,7 +47,7 @@ import org.jdrupes.vmoperator.util.DataPath;
"PMD.CouplingBetweenObjects" }) "PMD.CouplingBetweenObjects" })
public class VmDefinition extends K8sDynamicModel { public class VmDefinition extends K8sDynamicModel {
@SuppressWarnings("PMD.FieldNamingConventions") @SuppressWarnings({ "PMD.FieldNamingConventions", "unused" })
private static final Logger logger private static final Logger logger
= Logger.getLogger(VmDefinition.class.getName()); = Logger.getLogger(VmDefinition.class.getName());
@SuppressWarnings("PMD.FieldNamingConventions") @SuppressWarnings("PMD.FieldNamingConventions")
@ -62,7 +57,7 @@ public class VmDefinition extends K8sDynamicModel {
= new ObjectMapper().registerModule(new JavaTimeModule()); = new ObjectMapper().registerModule(new JavaTimeModule());
private final Model model; private final Model model;
private final Map<String, Object> extra = new ConcurrentHashMap<>(); private VmExtraData extraData;
/** /**
* The VM state from the VM definition. * The VM state from the VM definition.
@ -295,27 +290,21 @@ public class VmDefinition extends K8sDynamicModel {
} }
/** /**
* Set extra data (locally used, unknown to kubernetes). * Set extra data (unknown to kubernetes).
*
* @param property the property
* @param value the value
* @return the VM definition * @return the VM definition
*/ */
public VmDefinition extra(String property, Object value) { /* default */ VmDefinition extra(VmExtraData extraData) {
extra.put(property, value); this.extraData = extraData;
return this; return this;
} }
/** /**
* Return extra data. * Return the extra data.
* *
* @param <T> the generic type * @return the data
* @param property the property
* @return the object
*/ */
@SuppressWarnings("unchecked") public Optional<VmExtraData> extra() {
public <T> T extra(String property) { return Optional.ofNullable(extraData);
return (T) extra.get(property);
} }
/** /**
@ -403,78 +392,6 @@ public class VmDefinition extends K8sDynamicModel {
.map(Number::longValue); .map(Number::longValue);
} }
/**
* Create a connection file.
*
* @param password the password
* @param preferredIpVersion the preferred IP version
* @param deleteConnectionFile the delete connection file
* @return the string
*/
public String connectionFile(String password,
Class<?> preferredIpVersion, boolean deleteConnectionFile) {
var addr = displayIp(preferredIpVersion);
if (addr.isEmpty()) {
logger.severe(() -> "Failed to find display IP for " + name());
return null;
}
var port = this.<Number> fromVm("display", "spice", "port")
.map(Number::longValue);
if (port.isEmpty()) {
logger.severe(() -> "No port defined for display of " + name());
return null;
}
StringBuffer data = new StringBuffer(100)
.append("[virt-viewer]\ntype=spice\nhost=")
.append(addr.get().getHostAddress()).append("\nport=")
.append(port.get().toString())
.append('\n');
if (password != null) {
data.append("password=").append(password).append('\n');
}
this.<String> fromVm("display", "spice", "proxyUrl")
.ifPresent(u -> {
if (!Strings.isNullOrEmpty(u)) {
data.append("proxy=").append(u).append('\n');
}
});
if (deleteConnectionFile) {
data.append("delete-this-file=1\n");
}
return data.toString();
}
private Optional<InetAddress> displayIp(Class<?> preferredIpVersion) {
Optional<String> server = fromVm("display", "spice", "server");
if (server.isPresent()) {
var srv = server.get();
try {
var addr = InetAddress.getByName(srv);
logger.fine(() -> "Using IP address from CRD for "
+ getMetadata().getName() + ": " + addr);
return Optional.of(addr);
} catch (UnknownHostException e) {
logger.log(Level.SEVERE, e, () -> "Invalid server address "
+ srv + ": " + e.getMessage());
return Optional.empty();
}
}
var addrs = Optional.<List<String>> ofNullable(
extra("nodeAddresses")).orElse(Collections.emptyList()).stream()
.map(a -> {
try {
return InetAddress.getByName(a);
} catch (UnknownHostException e) {
logger.warning(() -> "Invalid IP address: " + a);
return null;
}
}).filter(a -> a != null).toList();
logger.fine(() -> "Known IP addresses for " + name() + ": " + addrs);
return addrs.stream()
.filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
.findFirst().or(() -> addrs.stream().findFirst());
}
/** /**
* Hash code. * Hash code.
* *

View file

@ -0,0 +1,171 @@
/*
* VM-Operator
* Copyright (C) 2025 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.common;
import io.kubernetes.client.util.Strings;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Represents internally used dynamic data associated with a
* {@link VmDefinition}.
*/
public class VmExtraData {
@SuppressWarnings("PMD.FieldNamingConventions")
private static final Logger logger
= Logger.getLogger(VmExtraData.class.getName());
private final VmDefinition vmDef;
private String nodeName = "";
private List<String> nodeAddresses = Collections.emptyList();
private long resetCount;
/**
* Initializes a new instance.
*
* @param vmDef the VM definition
*/
public VmExtraData(VmDefinition vmDef) {
this.vmDef = vmDef;
vmDef.extra(this);
}
/**
* Sets the node info.
*
* @param name the name
* @param addresses the addresses
* @return the VM extra data
*/
public VmExtraData nodeInfo(String name, List<String> addresses) {
nodeName = name;
nodeAddresses = addresses;
return this;
}
/**
* Return the node name.
*
* @return the string
*/
public String nodeName() {
return nodeName;
}
/**
* Sets the reset count.
*
* @param resetCount the reset count
* @return the vm extra data
*/
public VmExtraData resetCount(long resetCount) {
this.resetCount = resetCount;
return this;
}
/**
* Returns the reset count.
*
* @return the long
*/
public long resetCount() {
return resetCount;
}
/**
* Create a connection file.
*
* @param password the password
* @param preferredIpVersion the preferred IP version
* @param deleteConnectionFile the delete connection file
* @return the string
*/
public String connectionFile(String password,
Class<?> preferredIpVersion, boolean deleteConnectionFile) {
var addr = displayIp(preferredIpVersion);
if (addr.isEmpty()) {
logger
.severe(() -> "Failed to find display IP for " + vmDef.name());
return null;
}
var port = vmDef.<Number> fromVm("display", "spice", "port")
.map(Number::longValue);
if (port.isEmpty()) {
logger
.severe(() -> "No port defined for display of " + vmDef.name());
return null;
}
StringBuffer data = new StringBuffer(100)
.append("[virt-viewer]\ntype=spice\nhost=")
.append(addr.get().getHostAddress()).append("\nport=")
.append(port.get().toString())
.append('\n');
if (password != null) {
data.append("password=").append(password).append('\n');
}
vmDef.<String> fromVm("display", "spice", "proxyUrl")
.ifPresent(u -> {
if (!Strings.isNullOrEmpty(u)) {
data.append("proxy=").append(u).append('\n');
}
});
if (deleteConnectionFile) {
data.append("delete-this-file=1\n");
}
return data.toString();
}
private Optional<InetAddress> displayIp(Class<?> preferredIpVersion) {
Optional<String> server = vmDef.fromVm("display", "spice", "server");
if (server.isPresent()) {
var srv = server.get();
try {
var addr = InetAddress.getByName(srv);
logger.fine(() -> "Using IP address from CRD for "
+ vmDef.metadata().getName() + ": " + addr);
return Optional.of(addr);
} catch (UnknownHostException e) {
logger.log(Level.SEVERE, e, () -> "Invalid server address "
+ srv + ": " + e.getMessage());
return Optional.empty();
}
}
var addrs = nodeAddresses.stream().map(a -> {
try {
return InetAddress.getByName(a);
} catch (UnknownHostException e) {
logger.warning(() -> "Invalid IP address: " + a);
return null;
}
}).filter(Objects::nonNull).toList();
logger.fine(
() -> "Known IP addresses for " + vmDef.name() + ": " + addrs);
return addrs.stream()
.filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
.findFirst().or(() -> addrs.stream().findFirst());
}
}

View file

@ -53,7 +53,7 @@ data:
# i.e. if you start the VM without a value for this property, and # i.e. if you start the VM without a value for this property, and
# decide to trigger a reset later, you have to first set the value # decide to trigger a reset later, you have to first set the value
# and then inrement it. # and then inrement it.
resetCounter: ${ cr.extra("resetCount")?c } resetCounter: ${ cr.extra().get().resetCount()?c }
# Forward the cloud-init data if provided # Forward the cloud-init data if provided
<#if spec.cloudInit??> <#if spec.cloudInit??>

View file

@ -248,7 +248,7 @@ public class Reconciler extends Component {
public void onResetVm(ResetVm event, VmChannel channel) public void onResetVm(ResetVm event, VmChannel channel)
throws ApiException, IOException, TemplateException { throws ApiException, IOException, TemplateException {
var vmDef = channel.vmDefinition(); var vmDef = channel.vmDefinition();
vmDef.extra("resetCount", vmDef.<Long> extra("resetCount") + 1); vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1));
Map<String, Object> model Map<String, Object> model
= prepareModel(channel.client(), channel.vmDefinition()); = prepareModel(channel.client(), channel.vmDefinition());
cmReconciler.reconcile(model, channel); cmReconciler.reconcile(model, channel);

View file

@ -43,6 +43,7 @@ import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmDefinitions;
import org.jdrupes.vmoperator.common.VmExtraData;
import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.common.VmPool;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
@ -139,7 +140,7 @@ public class VmMonitor extends
} }
if (vmDef.data() != null) { if (vmDef.data() != null) {
// New data, augment and save // New data, augment and save
addDynamicData(channel.client(), vmDef, channel.vmDefinition()); addExtraData(channel.client(), vmDef, channel.vmDefinition());
channel.setVmDefinition(vmDef); channel.setVmDefinition(vmDef);
} else { } else {
// Reuse cached (e.g. if deleted) // Reuse cached (e.g. if deleted)
@ -178,17 +179,14 @@ public class VmMonitor extends
} }
@SuppressWarnings("PMD.AvoidDuplicateLiterals") @SuppressWarnings("PMD.AvoidDuplicateLiterals")
private void addDynamicData(K8sClient client, VmDefinition vmDef, private void addExtraData(K8sClient client, VmDefinition vmDef,
VmDefinition prevState) { VmDefinition prevState) {
// Maintain (or initialize) the resetCount var extra = new VmExtraData(vmDef);
vmDef.extra("resetCount",
Optional.ofNullable(prevState).map(d -> d.extra("resetCount"))
.orElse(0L));
// Node information // Maintain (or initialize) the resetCount
// Add defaults in case the VM is not running extra.resetCount(
vmDef.extra("nodeName", ""); Optional.ofNullable(prevState).flatMap(VmDefinition::extra)
vmDef.extra("nodeAddress", ""); .map(VmExtraData::resetCount).orElse(0L));
// VM definition status changes before the pod terminates. // VM definition status changes before the pod terminates.
// This results in pod information being shown for a stopped // This results in pod information being shown for a stopped
@ -196,6 +194,8 @@ public class VmMonitor extends
if (!vmDef.conditionStatus("Running").orElse(false)) { if (!vmDef.conditionStatus("Running").orElse(false)) {
return; return;
} }
// Get pod and extract node information.
var podSearch = new ListOptions(); var podSearch = new ListOptions();
podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME
+ ",app.kubernetes.io/component=" + APP_NAME + ",app.kubernetes.io/component=" + APP_NAME
@ -205,16 +205,15 @@ public class VmMonitor extends
= K8sV1PodStub.list(client, namespace(), podSearch); = K8sV1PodStub.list(client, namespace(), podSearch);
for (var podStub : podList) { for (var podStub : podList) {
var nodeName = podStub.model().get().getSpec().getNodeName(); var nodeName = podStub.model().get().getSpec().getNodeName();
vmDef.extra("nodeName", nodeName); logger.fine(() -> "Adding node name " + nodeName
logger.fine(() -> "Added node name " + nodeName
+ " to VM info for " + vmDef.name()); + " to VM info for " + vmDef.name());
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
var addrs = new ArrayList<String>(); var addrs = new ArrayList<String>();
podStub.model().get().getStatus().getPodIPs().stream() podStub.model().get().getStatus().getPodIPs().stream()
.map(ip -> ip.getIp()).forEach(addrs::add); .map(ip -> ip.getIp()).forEach(addrs::add);
vmDef.extra("nodeAddresses", addrs); logger.fine(() -> "Adding node addresses " + addrs
logger.fine(() -> "Added node addresses " + addrs
+ " to VM info for " + vmDef.name()); + " to VM info for " + vmDef.name());
extra.nodeInfo(nodeName, addrs);
} }
} catch (ApiException e) { } catch (ApiException e) {
logger.log(Level.WARNING, e, logger.log(Level.WARNING, e,

View file

@ -810,13 +810,12 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
} }
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> { e -> {
var data = vmDef.connectionFile(e.password().orElse(null), vmDef.extra()
preferredIpVersion, deleteConnectionFile); .map(xtra -> xtra.connectionFile(e.password().orElse(null),
if (data == null) { preferredIpVersion, deleteConnectionFile))
return; .ifPresent(
} cf -> channel.respond(new NotifyConletView(type(),
channel.respond(new NotifyConletView(type(), model.getConletId(), "openConsole", cf)));
model.getConletId(), "openConsole", data));
}); });
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }

View file

@ -41,6 +41,7 @@ import java.util.Set;
import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.common.VmExtraData;
import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ModifyVm;
@ -252,7 +253,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
"name", vmDef.name()), "name", vmDef.name()),
"spec", spec, "spec", spec,
"status", status, "status", status,
"nodeName", vmDef.extra("nodeName"), "nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""),
"permissions", vmDef.permissionsFor(user, roles).stream() "permissions", vmDef.permissionsFor(user, roles).stream()
.map(VmDefinition.Permission::toString).toList()); .map(VmDefinition.Permission::toString).toList());
} }
@ -484,13 +485,11 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
} }
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> { e -> {
var data = vmDef.connectionFile(e.password().orElse(null), vmDef.extra().map(xtra -> xtra.connectionFile(
preferredIpVersion, deleteConnectionFile); e.password().orElse(null), preferredIpVersion,
if (data == null) { deleteConnectionFile)).ifPresent(
return; cf -> channel.respond(new NotifyConletView(type(),
} model.getConletId(), "openConsole", cf)));
channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", data));
}); });
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }