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 ListgetControllerRequirements(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"