Rudi Schlatte c6fa96a51f Handle cost parameters, solver initialization
- Wait with app deployment until utility evaluator has sent us the cost
  parameter list.

- Emit cost parameters in AMPL model

- Send both AMPL model and data to the solver once it's started

- Pretty-print json files

Change-Id: I99b67a10a60883bb411ad486bb8ef92fec175350
2024-04-17 18:20:18 +02:00

613 lines
27 KiB
Java

package eu.nebulouscloud.optimiser.controller;
import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import eu.nebulouscloud.exn.core.Publisher;
import lombok.Getter;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;
import static net.logstash.logback.argument.StructuredArguments.keyValue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.ow2.proactive.sal.model.NodeCandidate;
import org.ow2.proactive.sal.model.Requirement;
/**
* Internal representation of a NebulOus app.
*/
@Slf4j
public class NebulousApp {
/**
* The UUID of the app. This identifies a specific application's ActiveMQ
* messages, etc. Chosen by the UI, unique across a NebulOuS
* installation.
*/
@Getter
private String UUID;
/**
* The app name, a user-readable string. Not safe to assume that this is
* a unique value.
*/
@Getter private String name;
/**
* The cluster name. This must be globally unique but should be short,
* since during deployment it will be used to create instance names, where
* AWS has a length restriction.
*/
@Getter private String clusterName;
/**
* The application status.
*
* <p>NEW: The application has been created from the GUI and is waiting
* for the performance indicators.
*
* <p>READY: The application is ready for deployment.
*
* <p>DEPLOYING: The application is being deployed or redeployed.
*
* <p>SOLVER_WAITING: The application is deployed, we're waiting for the
* solver to be ready so we can send AMPL and performance indicators.
*
* <p>RUNNING: The application is running, and under redeployment.
*
* <p>FAILED: The application is in an invalid state: one or more messages
* could not be parsed, or deployment or redeployment failed.
*/
public enum State {
NEW,
READY,
DEPLOYING,
SOLVER_WAITING,
RUNNING,
FAILED;
}
@Getter
private State state;
// ----------------------------------------
// App message parsing stuff
/** Location of the kubevela yaml file in the app creation message (String) */
private static final JsonPointer kubevela_path = JsonPointer.compile("/content");
/** Location of the variables (optimizable locations) of the kubevela file
* in the app creation message. (Array of objects) */
private static final JsonPointer variables_path = JsonPointer.compile("/variables");
/** Locations of the UUID and name in the app creation message (String) */
private static final JsonPointer uuid_path = JsonPointer.compile("/uuid");
private static final JsonPointer name_path = JsonPointer.compile("/title");
private static final JsonPointer utility_function_path = JsonPointer.compile("/utilityFunctions");
private static final JsonPointer constraints_path = JsonPointer.compile("/sloViolations");
/** The YAML converter */
// Note that instantiating this is apparently expensive, so we do it only once
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
/** General-purpose object mapper */
private static final ObjectMapper jsonMapper = new ObjectMapper();
// ----------------------------------------
// AMPL stuff
/** The array of KubeVela variables in the app message. */
@Getter private Map<String, JsonNode> kubevelaVariables = new HashMap<>();
/** Map from AMPL variable name to location in KubeVela. */
private Map<String, JsonPointer> kubevelaVariablePaths = new HashMap<>();
/** The app's raw metrics, a map from key to the defining JSON node. */
@Getter private Map<String, JsonNode> rawMetrics = new HashMap<>();
/** The app's composite metrics, a map from key to the defining JSON node. */
@Getter private Map<String, JsonNode> compositeMetrics = new HashMap<>();
/** The app's performance indicators, a map from key to the defining JSON node. */
@Getter private Map<String, JsonNode> performanceIndicators = new HashMap<>();
/** The app's "relevant" performance indicators, as calculated by the
* utility evaluator. Initialized to empty object for testing purposes. */
@Getter private JsonNode relevantPerformanceIndicators = jsonMapper.createObjectNode();
/** The app's utility functions; the AMPL solver will optimize for one of these. */
@Getter private Map<String, JsonNode> utilityFunctions = new HashMap<>();
/**
* The constraints that are actually relevant for the optimizer. If a
* constraint does not contain a variable, we cannot influence it via the
* solver, so it should not be included in the AMPL file.
*/
@Getter private Set<JsonNode> effectiveConstraints = new HashSet<>();
// ----------------------------------------
// Deployment stuff
/** The original app message. */
@Getter private JsonNode originalAppMessage;
private ObjectNode originalKubevela;
/**
* The current "generation" of deployment. Initial deployment sets this
* to 1, each subsequent redeployment increases by 1. This value is used
* to name node instances generated during that deployment.
*/
@Getter
private int deployGeneration = 0;
/**
* Unmodifiable map of component name to node name(s) deployed for that
* component. Component names are defined in the KubeVela file. We
* assume that component names stay constant during redeployment, i.e.,
* once an application is deployed, its KubeVela file will not change.
*
* Note that this map does not include the master node, since this is not
* specified in KubeVela.
*/
@Getter
private Map<String, Set<String>> componentNodeNames = Map.of();
/**
* Unmodifiable map from node name to deployed edge or BYON node
* candidate. We keep track of assigned edge candidates, since we do not
* want to doubly-assign edge nodes. We also store the node name, so we
* can "free" the edge candidate when the current component gets
* redeployed and lets go of its edge node. (We do not track cloud node
* candidates since these can be instantiated multiple times.)
*/
@Getter
private Map<String, NodeCandidate> nodeEdgeCandidates = Map.of();
/** Unmodifiable map of component name to its requirements, as currently
* deployed. Each replica of a component has identical requirements. */
@Getter
private Map<String, List<Requirement>> componentRequirements = Map.of();
/** Unmodifiable map of component name to its replica count, as currently
* deployed. */
@Getter
private Map<String, Integer> componentReplicaCounts = Map.of();
/** When an app gets deployed, this is where we send the AMPL file */
private Publisher ampl_message_channel;
// /** Have we ever been deployed? I.e., when we rewrite KubeVela, are there
// * already nodes running for us? */
// private boolean deployed = false;
/** The KubeVela as it was most recently sent to the app's controller. */
@Getter
private JsonNode deployedKubevela;
/** For each KubeVela component, the number of deployed nodes. All nodes
* will be identical wrt machine type etc. Unmodifiable map. */
@Getter
private Map<String, Integer> deployedNodeCounts = Map.of();
/** For each KubeVela component, the requirements for its node(s). Unmodifiable map. */
@Getter
private Map<String, List<Requirement>> deployedNodeRequirements = Map.of();
/**
* The EXN connector for this class. At the moment all apps share the
* same instance, but probably every app should have their own, out of
* thread-safety concerns.
*/
@Getter
private ExnConnector exnConnector;
/**
* Creates a NebulousApp object.
*
* @param app_message The whole app creation message (JSON)
* @param kubevela A parsed representation of the deployable KubeVela App model (YAML)
* @param ampl_message_channel A publisher for sending the generated AMPL file, or null
*/
// Note that example KubeVela and parameter files can be found at
// optimiser-controller/src/test/resources/
public NebulousApp(JsonNode app_message, ObjectNode kubevela, ExnConnector exnConnector) {
this.UUID = app_message.at(uuid_path).textValue();
this.name = app_message.at(name_path).textValue();
this.state = State.NEW;
this.clusterName = NebulousApps.calculateUniqueClusterName(this.UUID);
this.originalAppMessage = app_message;
this.originalKubevela = kubevela;
this.exnConnector = exnConnector;
JsonNode parameters = app_message.at(variables_path);
if (parameters.isArray()) {
for (JsonNode p : parameters) {
kubevelaVariables.put(p.get("key").asText(), p);
kubevelaVariablePaths.put(p.get("key").asText(),
JsonPointer.compile(p.get("path").asText()));
}
} else {
log.error("Cannot read parameters from app message, continuing without parameters",
keyValue("appId", UUID));
}
for (JsonNode f : originalAppMessage.withArray(utility_function_path)) {
utilityFunctions.put(f.get("name").asText(), f);
}
// We need to know which metrics are raw, composite, and which ones
// are performance indicators in disguise.
boolean done = false;
Set<JsonNode> metrics = StreamSupport.stream(
Spliterators.spliteratorUnknownSize(app_message.withArray("/metrics").elements(), Spliterator.ORDERED), false)
.collect(Collectors.toSet());
while (!done) {
// Pick out all raw metrics. Then pick out all composite metrics
// that only depend on raw metrics and composite metrics that only
// depend on raw metrics. The rest are performance indicators.
done = true;
Iterator<JsonNode> it = metrics.iterator();
while (it.hasNext()) {
JsonNode m = it.next();
if (m.get("type").asText().equals("raw")) {
rawMetrics.put(m.get("name").asText(), m);
it.remove();
done = false;
} else {
ArrayNode arguments = m.withArray("arguments");
boolean is_composite_metric = StreamSupport.stream(
Spliterators.spliteratorUnknownSize(arguments.elements(), Spliterator.ORDERED), false)
.allMatch(o -> rawMetrics.containsKey(o.asText()) || compositeMetrics.containsKey(o.asText()));
if (is_composite_metric) {
compositeMetrics.put(m.get("name").asText(), m);
it.remove();
done = false;
}
}
}
}
for (JsonNode m : metrics) {
// What's left is neither a raw nor composite metric.
performanceIndicators.put(m.get("name").asText(), m);
}
for (JsonNode f : app_message.withArray(utility_function_path)) {
// What's left is neither a raw nor composite metric.
utilityFunctions.put(f.get("name").asText(), f);
}
// In the current app message, constraints is not an array. When this
// changes, wrap this for loop in another loop over the constraints
// (Constraints are called sloViolations in the app message).
for (String key : app_message.withObject(constraints_path).findValuesAsText("metricName")) {
// Constraints that do not use variables, directly or via
// performance indicators, will be ignored.
if (kubevelaVariablePaths.keySet().contains(key)
|| performanceIndicators.keySet().contains(key)) {
effectiveConstraints.add(app_message.withObject(constraints_path));
break;
}
}
log.debug("New App instantiated.", keyValue("appName", name), keyValue("appId", UUID));
}
/**
* Create a NebulousApp object given an app creation message parsed into JSON.
*
* @param app_message the app creation message, including valid KubeVela
* YAML et al
* @param exnConnector The EXN connector to use for sending messages to
* the solver etc.
* @return a NebulousApp object, or null if `app_message` could not be
* parsed
*/
public static NebulousApp newFromAppMessage(JsonNode app_message, ExnConnector exnConnector) {
try {
String kubevela_string = app_message.at(kubevela_path).textValue();
String UUID = app_message.at(uuid_path).textValue();
JsonNode parameters = app_message.at(variables_path);
if (kubevela_string == null || !parameters.isArray()) {
log.error("Could not find kubevela or parameters in app creation message",
keyValue("appId", UUID));
return null;
} else {
Main.logFile("incoming-kubevela-" + UUID + ".yaml", kubevela_string);
return new NebulousApp(app_message,
(ObjectNode)readKubevelaString(kubevela_string), exnConnector);
}
} catch (Exception e) {
log.error("Could not read app creation message", e);
return null;
}
}
/**
* Set the state from NEW to READY, adding the list of relevant
* performance indicators. Return false if state was not READY.
*/
@Synchronized
public boolean setStateReady(JsonNode relevantPerformanceIndicators) {
if (state != State.NEW) {
return false;
} else {
state = State.READY;
this.relevantPerformanceIndicators = relevantPerformanceIndicators;
return true;
}
}
/**
* Set the state from READY to DEPLOYING, and increment the generation.
*
* @return false if deployment could not be started, true otherwise.
*/
@Synchronized
public boolean setStateDeploying() {
if (state != State.READY) {
return false;
} else {
state = State.DEPLOYING;
deployGeneration++;
return true;
}
}
/** Set state from DEPLOYING to RUNNING and update app cluster information.
* @return false if not in state deploying, otherwise true. */
@Synchronized
public boolean setStateDeploymentFinished(Map<String, List<Requirement>> componentRequirements, Map<String, Integer> nodeCounts, Map<String, Set<String>> componentNodeNames, Map<String, NodeCandidate> nodeEdgeCandidates, JsonNode deployedKubevela) {
if (state != State.DEPLOYING) {
return false;
} else {
// We keep all state read-only so we cannot modify the app object
// before we know deployment is successful
this.componentRequirements = Map.copyOf(componentRequirements);
this.componentReplicaCounts = Map.copyOf(nodeCounts);
this.componentNodeNames = Map.copyOf(componentNodeNames);
this.deployedKubevela = deployedKubevela;
this.nodeEdgeCandidates = Map.copyOf(nodeEdgeCandidates);
state = State.RUNNING;
return true;
}
}
/** Set state unconditionally to FAILED. No more state changes will be
* possible once the state is set to FAILED. */
public void setStateFailed() {
state = State.FAILED;
}
/** Utility function to parse a KubeVela string. Can be used from jshell. */
public static JsonNode readKubevelaString(String kubevela) throws JsonMappingException, JsonProcessingException {
return yamlMapper.readTree(kubevela);
}
/** Utility function to parse KubeVela from a file. Intended for use from jshell.
* @throws IOException
* @throws JsonProcessingException
* @throws JsonMappingException */
public static JsonNode readKubevelaFile(String path) throws JsonMappingException, JsonProcessingException, IOException {
return readKubevelaString(Files.readString(Path.of(path), StandardCharsets.UTF_8));
}
/**
* Check that all parameters have a name, type and path, and that the
* target path can be found in the original KubeVela file.
*
* @return true if all requirements hold, false otherwise
*/
public boolean validatePaths() {
for (final JsonNode param : kubevelaVariables.values()) {
String param_name = param.get("key").textValue();
if (param_name == null || param_name.equals("")) return false;
String param_type = param.get("type").textValue();
if (param_type == null || param_type.equals("")) return false;
// TODO: also validate types, upper and lower bounds, etc.
String target_path = param.get("path").textValue();
if (target_path == null || target_path.equals("")) return false;
JsonNode target = findPathInKubevela(target_path);
if (target == null) return false; // must exist
}
return true;
}
/**
* Return the location of a path in the application's KubeVela model.
*
* See https://datatracker.ietf.org/doc/html/rfc6901 for a specification
* of the path format.
*
* @param path the path to the requested node, in JSON Pointer notation.
* @return the node identified by the given path, or null if the path
* cannot be followed
*/
private JsonNode findPathInKubevela(String path) {
JsonNode result = originalKubevela.at(path);
return result.isMissingNode() ? null : result;
}
/**
* Replace variables in the original KubeVela with values calculated by
* the solver. We look up the paths of the variables in the `parameters`
* field.
*
* @param variableValues A JSON object with keys being variable names and
* their values the replacement value, for example:
*
* <pre>{@code { 'cpu': 8, 'memory': 4906 }}</pre>
*
* The variable names are generated by the UI and are cross-referenced
* with locations in the KubeVela file.
*
* @return the modified KubeVela YAML, or null if no KubeVela could be
* generated.
*/
public ObjectNode rewriteKubevelaWithSolution(ObjectNode variableValues) {
ObjectNode freshKubevela = originalKubevela.deepCopy();
for (Map.Entry<String, JsonNode> entry : variableValues.properties()) {
String key = entry.getKey();
JsonNode replacementValue = entry.getValue();
JsonNode param = kubevelaVariables.get(key);
JsonPointer path = kubevelaVariablePaths.get(key);
JsonNode nodeToBeReplaced = freshKubevela.at(path);
boolean doReplacement = true;
if (nodeToBeReplaced == null) {
// Didn't find location in KubeVela file (should never happen)
log.warn("Location {} not found in KubeVela, cannot replace with value {}",
key, replacementValue, keyValue("appId", UUID));
doReplacement = false;
} else if (param == null) {
// Didn't find parameter definition (should never happen)
log.warn("Variable {} not found in user input, cannot replace with value {}",
key, replacementValue, keyValue("appId", UUID));
doReplacement = false;
} else if (param.at("/meaning").asText().equals("memory")) {
// Special case: the solver delivers a number for memory, but
// KubeVela wants a unit.
if (!replacementValue.asText().endsWith("Mi")) {
// Don't add a second "Mi", just in case the solver has
// become self-aware and starts adding it on its own
replacementValue = new TextNode(replacementValue.asText() + "Mi");
}
} // Handle other special cases here, as they come up
if (doReplacement) {
ObjectNode parent = (ObjectNode)freshKubevela.at(path.head());
String property = path.last().getMatchingProperty();
parent.replace(property, replacementValue);
}
}
return freshKubevela;
}
/**
* Calculate AMPL file and send it off to the solver.
*
* <p> TODO: this should be done once from a message handler that listens
* for an incoming "solver ready" message
*
* <p> TODO: also send performance indicators to solver here
*/
public void sendAMPL() {
String ampl_model = AMPLGenerator.generateAMPL(this);
String ampl_data = relevantPerformanceIndicators.at("/initialDataFile").textValue();
ObjectNode msg = jsonMapper.createObjectNode();
msg.put("ModelFileName", getUUID() + ".ampl");
msg.put("ModelFileContent", ampl_model);
if (ampl_data != null && ampl_data.length() > 0) {
msg.put("DataFileName", getUUID() + ".dat");
msg.put("DataFileContent", ampl_data);
}
msg.put("ObjectiveFunction", getObjectiveFunction());
ObjectNode constants = msg.withObject("Constants");
// Define initial values for constant utility functions:
// "Constants" : {
// <constant utility function name> : {
// "Variable" : <AMPL Variable Name>
// "Value" : <value at the variable's path in original KubeVela>
// }
// }
for (final JsonNode function : originalAppMessage.withArray(utility_function_path)) {
if (!(function.get("type").asText().equals("constant")))
continue;
// NOTE: for a constant function, we rely on the fact that the
// function body is a single variable defined in the "Variables"
// section and pointing to KubeVela, and the
// `functionExpressionVariables` array contains one entry.
JsonNode variable = function.withArray("/expression/variables").get(0);
String variableName = variable.get("value").asText();
JsonPointer path = kubevelaVariablePaths.get(variableName);
JsonNode value = originalKubevela.at(path);
ObjectNode constant = constants.withObject(function.get("name").asText());
constant.put("Variable", variableName);
constant.set("Value", value);
}
log.info("Sending AMPL files to solver", keyValue("amplMessage", msg), keyValue("appId", UUID));
exnConnector.getAmplMessagePublisher().send(jsonMapper.convertValue(msg, Map.class), getUUID(), true);
Main.logFile("to-solver-" + getUUID() + ".json", msg.toPrettyString());
Main.logFile("to-solver-" + getUUID() + ".ampl", ampl_model);
}
/**
* Send the metric list for the given app. This is done two times: once
* before app cluster creation to initialize EMS, once after cluster app
* creation to initialize the solver.
*
* @param app The application under deployment.
*/
public void sendMetricList() {
Publisher metricsChannel = exnConnector.getMetricListPublisher();
ObjectNode msg = jsonMapper.createObjectNode();
ArrayNode metricNames = msg.withArray("/metrics");
getRawMetrics().forEach((k, v) -> metricNames.add(k));
getCompositeMetrics().forEach((k, v) -> metricNames.add(k));
log.info("Sending metric list", keyValue("appId", UUID));
metricsChannel.send(jsonMapper.convertValue(msg, Map.class), getUUID(), true);
Main.logFile("metric-names-" + getUUID() + ".json", msg.toPrettyString());
}
/**
* The objective function to use. In case the app creation message
* specifies more than one and doesn't indicate which one to use, choose
* the first one.
*
* @return the objective function specified in the app creation message.
*/
private String getObjectiveFunction() {
ArrayNode utility_functions = originalAppMessage.withArray(utility_function_path);
for (final JsonNode function : utility_functions) {
// do not optimize a constant function
if (!(function.get("type").asText().equals("constant"))) {
return function.get("name").asText();
}
}
log.warn("No non-constant utility function specified for application; solver will likely complain",
keyValue("appId", UUID));
return "";
}
/**
* Handle an incoming solver message. If the message has a field {@code
* deploySolution} with value {@code true}, rewrite the original KubeVela
* file with the contained variable values and perform initial deployment
* or redeployment as appropriate. Otherwise, ignore the message.
*
* @param solution The message from the solver, containing a field
* "VariableValues" that can be processed by {@link
* NebulousApp#rewriteKubevelaWithSolution}.
*/
public void processSolution(ObjectNode solution) {
if (!solution.get("DeploySolution").asBoolean(false)) {
// `asBoolean` returns its argument if node is missing or cannot
// be converted to Boolean
return;
}
ObjectNode variables = solution.withObjectProperty("VariableValues");
ObjectNode kubevela = rewriteKubevelaWithSolution(variables);
if (deployGeneration > 0) {
// We assume that killing a node will confuse the application's
// Kubernetes cluster, therefore:
// 1. Recalculate node sets
// 2. Tell SAL to start fresh nodes, passing in the deployment
// scripts
// 3. Send updated KubeVela for redeployment
// 4. Shut down superfluous nodes
NebulousAppDeployer.redeployApplication(this, kubevela);
} else {
// 1. Calculate node sets, including Nebulous controller node
// 2. Tell SAL to start all nodes, passing in the deployment
// scripts
// 3. Send KubeVela file for deployment
NebulousAppDeployer.deployApplication(this, kubevela);
}
}
/**
* Deploy an application, bypassing the solver. This just deploys the
* unmodified KubeVela, as given by the initial app creation message.
*/
public void deployUnmodifiedApplication() {
NebulousAppDeployer.deployApplication(this, originalKubevela);
}
}