Skip to content

Commit

Permalink
Handle serverless components
Browse files Browse the repository at this point in the history
- Skip serverless components when allocating VMs

- Add node affinity traits to components with `type: knative-serving` to
  refer to a node with `type: serverless-platform`.

Fixes #14
  • Loading branch information
rudi committed Jul 30, 2024
1 parent 96d68d4 commit b706f11
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.Set;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

/**
Expand All @@ -30,11 +31,9 @@ public class KubevelaAnalyzer {
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());

/**
* Return true if a component is a volume storage node. If true, such a
* node should not be rewritten during deployment, and no virtual machine
* should be created.<p>
* Return true if a component is a persistent volume storage node.<p>
*
* We currently look for one of the following structures in a component:
* We look for one of the following structures:
*
* <pre>{@code
* type: raw
Expand All @@ -56,15 +55,12 @@ public class KubevelaAnalyzer {
* Note: In the two samples above, the objects will have other attributes
* as well, which we omit for brevity.<p>
*
* Note: This method should be redesigned once we discover other kinds of
* nodes (currently we have compute nodes and volume storage nodes).<p>
*
* @param component The component; should be a child of the {@code spec:}
* top-level array in the KubeVela YAML.
*
* @return true if component is a volume storage node.
* @return true if component is a persisten volume component, false if not
*/
public static final boolean isVolumeStorageComponent(JsonNode component) {
public static boolean isVolumeComponent(JsonNode component) {
boolean form1 = component.at("/type").asText().equals("raw")
&& component.at("/properties/kind").asText().equals("PersistentVolumeClaim");
boolean form2 = component.at("/type").asText().equals("k8s-objects")
Expand All @@ -77,6 +73,76 @@ public static final boolean isVolumeStorageComponent(JsonNode component) {
return form1 || form2;
}

/**
* Return true if the component is a serverless component.<p>
*
* A component is serverless if it has {@code type: knative-serving}.
*
* @param component The component; should be a child of the {@code spec:}
* top-level array in the KubeVela YAML.
*
* @return true if component is serverless, false if not.
*/
public static final boolean isServerlessComponent(JsonNode component) {
return component.at("/type").asText().equals("knative-serving");
}

/**
* Return true if a component should not be rewritten during deployment,
* and no virtual machine should be generated.<p>
*
* Currently, we do not deploy volume storage nodes and serverless
* nodes.<p>
*
* <pre>{@code
* type: knative-serving
* }</pre>
*
* @param component The component; should be a child of the {@code spec:}
* top-level array in the KubeVela YAML.
*
* @return true if component should get a VM, false if not.
*/
public static boolean componentNeedsNode(JsonNode component) {
return !isVolumeComponent(component) && !isServerlessComponent(component);
}

public static boolean hasServerlessComponents(JsonNode kubevela) {
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(
kubevela.withArray("/components").elements(), Spliterator.ORDERED),
false)
.anyMatch(KubevelaAnalyzer::isServerlessComponent);
}

/**
* Return true if the component is a serverless-platform component.<p>
*
* A component is a serverless platform, i.e., should run serverless
* components, if it has {@code type: serverless-platform}.
*
* @param component The component; should be a child of the {@code spec:}
* top-level array in the KubeVela YAML.
*
* @return true if component is a serverless platform, false if not.
*/
public static final boolean isServerlessPlatform(JsonNode component) {
return component.at("/type").asText().equals("serverless-platform");
}

/**
* Find the names of all {@code serverless-platform} nodes.
*/
public static final List<String> findServerlessPlatformNames(JsonNode kubevela) {
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(
kubevela.withArray("/components").elements(), Spliterator.ORDERED),
false)
.filter(KubevelaAnalyzer::isServerlessPlatform)
.map((component) -> component.at("/name").asText())
.collect(Collectors.toUnmodifiableList());
}

/**
* Given a KubeVela file, extract how many nodes to deploy for each
* component. Note that this can be zero when the component should not be
Expand All @@ -101,7 +167,7 @@ public static Map<String, Integer> getNodeCount(JsonNode kubevela) {
Map<String, Integer> result = new HashMap<>();
ArrayNode components = kubevela.withArray("/spec/components");
for (final JsonNode c : components) {
if (isVolumeStorageComponent(c)) continue;
if (!componentNeedsNode(c)) continue;
result.put(c.get("name").asText(), 1); // default value; might get overwritten
for (final JsonNode t : c.withArray("/traits")) {
if (t.at("/type").asText().equals("scaler")
Expand Down Expand Up @@ -290,7 +356,7 @@ public static Map<String, List<Requirement>> getBoundedRequirements(JsonNode kub
Map<String, List<Requirement>> result = new HashMap<>();
ArrayNode components = kubevela.withArray("/spec/components");
for (final JsonNode c : components) {
if (isVolumeStorageComponent(c)) continue;
if (!componentNeedsNode(c)) continue;
String componentName = c.get("name").asText();
ArrayList<Requirement> reqs = new ArrayList<>();
if (includeNebulousRequirements) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static List<String> getMetricList(NebulousApp app) {
* @param kubevela the kubevela file, used to obtain default variable values.
* @return AMPL code for the solver.
*/
public static String generateAMPL(NebulousApp app, ObjectNode kubevela) {
public static String generateAMPL(NebulousApp app, JsonNode kubevela) {
final StringWriter result = new StringWriter();
final PrintWriter out = new PrintWriter(result);
out.format("# AMPL file for application '%s' with id %s%n", app.getName(), app.getUUID());
Expand Down Expand Up @@ -231,7 +231,7 @@ private static Set<String> usedMetrics(NebulousApp app) {
return result;
}

private static void generateVariablesSection(NebulousApp app, ObjectNode kubevela, PrintWriter out) {
private static void generateVariablesSection(NebulousApp app, JsonNode kubevela, PrintWriter out) {
out.println("# Variables");
for (final JsonNode p : app.getKubevelaVariables().values()) {
ObjectNode param = (ObjectNode) p;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public enum State {

/** The KubeVela as it was most recently sent to the app's controller. */
@Getter
private ObjectNode deployedKubevela = null;
private JsonNode deployedKubevela = null;

/**
* The EXN connector for this class. At the moment all apps share the
Expand Down Expand Up @@ -379,7 +379,7 @@ public boolean setStateDeploying() {
/** 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, ObjectNode deployedKubevela) {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static List<Requirement> getControllerRequirements(String jobID, Set<Stri
* During deployment and redeployment, we label all nodes with {@code
* nebulouscloud.eu/<componentname>=yes}. (Note that with this scheme, a
* node can have labels for multiple components if desired.) We add the
* following trait to all components:
* following trait to all normal components:
*
* <pre>{@code
* traits:
Expand All @@ -71,30 +71,60 @@ public static List<Requirement> getControllerRequirements(String jobID, Set<Stri
* values: "yes"
* }</pre>
*
* Persistent volume components do not get an affinity trait. Serverless
* components get an affinity for the {@code type: serverless-platform}
* node.
*
* @param kubevela the KubeVela specification to modify. This parameter is
* not modified.
* @return a fresh KubeVela specification with added nodeAffinity traits.
*/
public static ObjectNode createDeploymentKubevela(JsonNode kubevela) {
ObjectNode result = kubevela.deepCopy();
for (final JsonNode c : result.withArray("/spec/components")) {
// Do not add trait to components that define a volume
if (c.at("/type").asText().equals("raw")) continue;
String name = c.get("name").asText();
// Add traits
ArrayNode traits = c.withArray("traits");
ObjectNode trait = traits.addObject();
trait.put("type", "affinity");
ArrayNode nodeSelectorTerms = trait.withArray("/properties/nodeAffinity/required/nodeSelectorTerms");
ArrayNode matchExpressions = nodeSelectorTerms.addObject().withArray("matchExpressions");
ObjectNode term = matchExpressions.addObject();
term.put("key", "nebulouscloud.eu/" + name)
.put("operator", "In")
.withArray("values").add("yes");
// Remove resources
c.withObject("/properties").remove("memory");
c.withObject("/properties").remove("cpu");
c.withObject("/properties/resources").remove("requests");
public static ObjectNode createDeploymentKubevela(JsonNode kubevela) throws IllegalStateException {
final ObjectNode result = kubevela.deepCopy();
final ArrayNode components = result.withArray("/spec/components");
final List<String> serverlessPlatformNodes = KubevelaAnalyzer.findServerlessPlatformNames(result);
if (serverlessPlatformNodes.size() > 1) {
log.warn("More than one serverless platform node found, serverless components will run on {}", serverlessPlatformNodes.get(0));
}
for (final JsonNode c : components) {
if (KubevelaAnalyzer.isVolumeComponent(c)) {
// Persistent volume component: skip
continue;
} else if (KubevelaAnalyzer.isServerlessComponent(c)) {
// Serverless component: add trait to deploy on serverless-platform node
if (serverlessPlatformNodes.isEmpty()) {
throw new IllegalStateException("Trying to deploy serverless component without defining a serverless platform node");
}
ArrayNode traits = c.withArray("traits");
ObjectNode trait = traits.addObject();
trait.put("type", "affinity");
ArrayNode nodeSelectorTerms = trait.withArray("/properties/nodeAffinity/required/nodeSelectorTerms");
ArrayNode matchExpressions = nodeSelectorTerms.addObject().withArray("matchExpressions");
ObjectNode term = matchExpressions.addObject();
// TODO: figure out how to express multiple affinities; in
// case of multiple serverless-platform nodes, we want
// kubernetes to choose an arbitrary one.
term.put("key", "nebulouscloud.eu/" + serverlessPlatformNodes.get(0))
.put("operator", "In")
.withArray("values").add("yes");
} else {
// Normal component: add trait to deploy on its own machine
String name = c.get("name").asText();
// Add traits
ArrayNode traits = c.withArray("traits");
ObjectNode trait = traits.addObject();
trait.put("type", "affinity");
ArrayNode nodeSelectorTerms = trait.withArray("/properties/nodeAffinity/required/nodeSelectorTerms");
ArrayNode matchExpressions = nodeSelectorTerms.addObject().withArray("matchExpressions");
ObjectNode term = matchExpressions.addObject();
term.put("key", "nebulouscloud.eu/" + name)
.put("operator", "In")
.withArray("values").add("yes");
// Remove resources
c.withObject("/properties").remove("memory");
c.withObject("/properties").remove("cpu");
c.withObject("/properties/resources").remove("requests");
}
}
return result;
}
Expand Down Expand Up @@ -267,7 +297,14 @@ public static void deployApplication(NebulousApp app, JsonNode kubevela) {

// ------------------------------------------------------------
// Rewrite KubeVela
ObjectNode rewritten = createDeploymentKubevela(kubevela);
JsonNode rewritten;
try {
rewritten = createDeploymentKubevela(kubevela);
} catch (IllegalStateException e) {
log.error("Failed to create deployment kubevela", e);
app.setStateFailed();
return;
}
String rewritten_kubevela = "---\n# Did not manage to create rewritten KubeVela";
try {
rewritten_kubevela = yamlMapper.writeValueAsString(rewritten);
Expand Down Expand Up @@ -544,7 +581,14 @@ public static void redeployApplication(NebulousApp app, ObjectNode updatedKubeve

// ------------------------------------------------------------
// Rewrite KubeVela
JsonNode rewritten = createDeploymentKubevela(updatedKubevela);
JsonNode rewritten;
try {
rewritten = createDeploymentKubevela(updatedKubevela);
} catch (IllegalStateException e) {
log.error("Failed to create deployment kubevela", e);
app.setStateFailed();
return;
}
String rewritten_kubevela = "---\n# Did not manage to create rewritten KubeVela";
try {
rewritten_kubevela = yamlMapper.writeValueAsString(rewritten);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.ow2.proactive.sal.model.Requirement;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
Expand All @@ -22,6 +23,7 @@

import static org.junit.Assert.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class NebulousAppTests {
Expand Down Expand Up @@ -85,7 +87,7 @@ void replaceValueInKubevela() throws IOException, URISyntaxException {
}

@Test
void calculateNodeRequirements() throws IOException, URISyntaxException {
void calculateNodeRequirementsSize() throws IOException, URISyntaxException {
String kubevela_str = Files.readString(getResourcePath("vela-deployment-v2.yml"),
StandardCharsets.UTF_8);
JsonNode kubevela = yaml_mapper.readTree(kubevela_str);
Expand All @@ -98,6 +100,20 @@ void calculateNodeRequirements() throws IOException, URISyntaxException {
assertTrue(requirements.size() == kubevela.withArray("/spec/components").size());
}

@Test
void calculateServerlessRequirementsSize() throws IOException, URISyntaxException {
JsonNode kubevela = KubevelaAnalyzer.parseKubevela(Files.readString(getResourcePath("serverless-deployment.yaml"), StandardCharsets.UTF_8));
Map<String, List<Requirement>> requirements = KubevelaAnalyzer.getBoundedRequirements(kubevela, null);
// We have one serverless component, so we need n-1 VMs
assertTrue(requirements.size() == kubevela.withArray("/spec/components").size() - 1);
}

@Test
void checkInvalidServerlessDeployment() throws JsonProcessingException, IOException, URISyntaxException {
JsonNode kubevela = KubevelaAnalyzer.parseKubevela(Files.readString(getResourcePath("invalid-serverless-deployment.yaml"), StandardCharsets.UTF_8));
assertThrows(IllegalStateException.class, () -> NebulousAppDeployer.createDeploymentKubevela(kubevela));
}

// @Test
void calculateRewrittenNodeRequirements() throws IOException, URISyntaxException {
// TODO: reinstate with `app-creation-message-mercabana.json` after we
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: first-app
spec:
components:
- name: helloworld
type: webservice
properties:
image: oamdev/helloworld-python:v1
env:
- name: "TARGET"
value: "KubeVela"
port: 8080
- name: serverless-backend
type: webservice
properties:
resources:
requests:
cpu: "2" # Requests for CPU in cores
memory: "2Gi" # Requests for memory in GiB
replicas: 3 # Number of replicas
- name: backend
type: knative-serving
properties:
image: gcr.io/knative-samples/helloworld-go
env:
- name: TARGET
value: "Go Sample v1"
29 changes: 29 additions & 0 deletions optimiser-controller/src/test/resources/serverless-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: first-app
spec:
components:
- name: helloworld
type: webservice
properties:
image: oamdev/helloworld-python:v1
env:
- name: "TARGET"
value: "KubeVela"
port: 8080
- name: serverless-backend
type: serverless-platform
properties:
resources:
requests:
cpu: "2" # Requests for CPU in cores
memory: "2Gi" # Requests for memory in GiB
replicas: 3 # Number of replicas
- name: backend
type: knative-serving
properties:
image: gcr.io/knative-samples/helloworld-go
env:
- name: TARGET
value: "Go Sample v1"

0 comments on commit b706f11

Please sign in to comment.