diff --git a/nebulous-requirements-extractor/src/main/java/eu/nebulouscloud/optimiser/kubevela/KubevelaAnalyzer.java b/nebulous-requirements-extractor/src/main/java/eu/nebulouscloud/optimiser/kubevela/KubevelaAnalyzer.java index 682a4dc..ecc19c0 100644 --- a/nebulous-requirements-extractor/src/main/java/eu/nebulouscloud/optimiser/kubevela/KubevelaAnalyzer.java +++ b/nebulous-requirements-extractor/src/main/java/eu/nebulouscloud/optimiser/kubevela/KubevelaAnalyzer.java @@ -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; /** @@ -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.

+ * Return true if a component is a persistent volume storage node.

* - * We currently look for one of the following structures in a component: + * We look for one of the following structures: * *

{@code
      * type: raw
@@ -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.

* - * Note: This method should be redesigned once we discover other kinds of - * nodes (currently we have compute nodes and volume storage nodes).

- * * @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") @@ -77,6 +73,76 @@ public static final boolean isVolumeStorageComponent(JsonNode component) { return form1 || form2; } + /** + * Return true if the component is a serverless component.

+ * + * 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.

+ * + * Currently, we do not deploy volume storage nodes and serverless + * nodes.

+ * + *

{@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 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.

+ * + * 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 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 @@ -101,7 +167,7 @@ public static Map getNodeCount(JsonNode kubevela) { Map 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") @@ -290,7 +356,7 @@ public static Map> getBoundedRequirements(JsonNode kub Map> 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 reqs = new ArrayList<>(); if (includeNebulousRequirements) { diff --git a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/AMPLGenerator.java b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/AMPLGenerator.java index 0aa6a69..97d5bfb 100644 --- a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/AMPLGenerator.java +++ b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/AMPLGenerator.java @@ -41,7 +41,7 @@ public static List 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()); @@ -231,7 +231,7 @@ private static Set 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; diff --git a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousApp.java b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousApp.java index 2529fae..8229fad 100644 --- a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousApp.java +++ b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousApp.java @@ -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 @@ -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> componentRequirements, Map nodeCounts, Map> componentNodeNames, Map nodeEdgeCandidates, ObjectNode deployedKubevela) { + public boolean setStateDeploymentFinished(Map> componentRequirements, Map nodeCounts, Map> componentNodeNames, Map nodeEdgeCandidates, JsonNode deployedKubevela) { if (state != State.DEPLOYING) { return false; } else { diff --git a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousAppDeployer.java b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousAppDeployer.java index 056987e..d28aefd 100644 --- a/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousAppDeployer.java +++ b/optimiser-controller/src/main/java/eu/nebulouscloud/optimiser/controller/NebulousAppDeployer.java @@ -56,7 +56,7 @@ public static List getControllerRequirements(String jobID, Set=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: * *

{@code
      * traits:
@@ -71,30 +71,60 @@ public static List getControllerRequirements(String jobID, Set
      *
+     * 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 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;
     }
@@ -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);
@@ -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);
diff --git a/optimiser-controller/src/test/java/eu/nebulouscloud/optimiser/controller/NebulousAppTests.java b/optimiser-controller/src/test/java/eu/nebulouscloud/optimiser/controller/NebulousAppTests.java
index 8bb2f9b..acf9074 100644
--- a/optimiser-controller/src/test/java/eu/nebulouscloud/optimiser/controller/NebulousAppTests.java
+++ b/optimiser-controller/src/test/java/eu/nebulouscloud/optimiser/controller/NebulousAppTests.java
@@ -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;
@@ -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 {
@@ -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);
@@ -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> 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
diff --git a/optimiser-controller/src/test/resources/invalid-serverless-deployment.yaml b/optimiser-controller/src/test/resources/invalid-serverless-deployment.yaml
new file mode 100644
index 0000000..08258e3
--- /dev/null
+++ b/optimiser-controller/src/test/resources/invalid-serverless-deployment.yaml
@@ -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"
diff --git a/optimiser-controller/src/test/resources/serverless-deployment.yaml b/optimiser-controller/src/test/resources/serverless-deployment.yaml
new file mode 100644
index 0000000..a384c86
--- /dev/null
+++ b/optimiser-controller/src/test/resources/serverless-deployment.yaml
@@ -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"