Repository: LangStream/langstream (language: python)

Issue:
Applications have a name length limitation due to pod names are at most 63 characters
From @ddieruf 
```
Caused by: io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: POST at: 
https://10.96.0.1:443/apis/batch/v1/namespaces/sga-my-tenant/jobs. Message: Job.batch 
"sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat" is invalid: 
spec.template.labels: Invalid value: "sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat": 
must be no more than 63 characters. Received status: Status(apiVersion=v1, code=422, details=StatusDetails(causes=
[StatusCause(field=spec.template.labels, message=Invalid value: 
"sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat": must be no more than 63 characters, reason=FieldValueInvalid, additionalProperties={}), StatusCause(field=spec.template.labels, 
message=Invalid value: "sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat": must be no more than 63 characters, reason=FieldValueInvalid, additionalProperties={})], group=batch, 
kind=Job, name=sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat, retryAfterSeconds=null, uid=null, additionalProperties={}), kind=Status, message=Job.batch "sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat" is invalid: spec.template.labels: Invalid value: "sga-runtime-deployer-flatten-transformation-using-astra-streaming-example-applicat": must be no more than 63 characters, metadata=ListMeta(_continue=null, remainingItemCount=null, resourceVersion=null, selfLink=null, additionalProperties={}), reason=Invalid, status=Failure, additionalProperties={}).
    at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.requestFailure(OperationSupport.java:671)
    at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.requestFailure(OperationSupport.java:651)
    at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.assertResponseCode(OperationSupport.java:600)
    at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.lambda$handleResponse$0(OperationSupport.java:560)
    at java.base/java.util.concurrent.CompletableFuture$UniApply.tryFire(CompletableFuture.java:646)
    at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
    at java.base/java.util.concurrent.CompletableFuture.complete(CompletableFuture.java:2147)
    at io.fabric8.kubernetes.client.http.StandardHttpClient.lambda$completeOrCancel$10(StandardHttpClient.java:140)
    at java.base/java.util.concurrent.CompletableFuture.uniWhenComplete(CompletableFuture.java:863)
    at java.base/java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(CompletableFuture.java:841)
    at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
    at java.base/java.util.concurrent.CompletableFuture.complete(CompletableFuture.java:2147)
    at io.fabric8.kubernetes.client.http.ByteArrayBodyHandler.onBodyDone(ByteArrayBodyHandler.java:52)
    at java.base/java.util.concurrent.CompletableFuture.uniWhenComplete(CompletableFuture.java:863)
    at java.base/java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(CompletableFuture.java:841)
    at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
    at java.base/java.util.concurrent.CompletableFuture.complete(CompletableFuture.java:2147)
    at io.fabric8.kubernetes.client.vertx.VertxHttpRequest.lambda$null$1(VertxHttpRequest.java:122)
    at io.vertx.core.impl.ContextInternal.dispatch(ContextInternal.java:264)
    at io.vertx.core.impl.ContextInternal.dispatch(ContextInternal.java:246)
    at io.vertx.core.http.impl.HttpEventHandler.handleEnd(HttpEventHandler.java:76)
    at io.vertx.core.http.impl.HttpClientResponseImpl.handleEnd(HttpClientResponseImpl.java:250)
    at io.vertx.core.http.impl.Http1xClientConnection$StreamImpl.lambda$new$0(Http1xClientConnection.java:444)
    at io.vertx.core.streams.impl.InboundBuffer.handleEvent(InboundBuffer.java:255)
    at io.vertx.core.streams.impl.InboundBuffer.write(InboundBuffer.java:134)
    at io.vertx.core.http.impl.Http1xClientConnection$StreamImpl.handleEnd(Http1xClientConnection.java:708)
    at io.vertx.core.impl.EventLoopContext.lambda$execute$2(EventLoopContext.java:78)
    at io.netty.util.concurrent.AbstractEventExecutor.runTask(AbstractEventExecutor.java:174)
    at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java:167)
    at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:470)
    at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:569)
    at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
    at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
```


Discussion / hints:

Fix patch:
diff --git a/core/pom.xml b/core/pom.xml
index f2792c57e..c170c6db0 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -68,5 +68,9 @@
       <artifactId>awaitility</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-lang3</artifactId>
+    </dependency>
   </dependencies>
 </project>
diff --git a/core/src/main/java/com/datastax/oss/sga/impl/agents/ai/GenAIToolKitFunctionAgentProvider.java b/core/src/main/java/com/datastax/oss/sga/impl/agents/ai/GenAIToolKitFunctionAgentProvider.java
index 602eade9f..7be2f701f 100644
--- a/core/src/main/java/com/datastax/oss/sga/impl/agents/ai/GenAIToolKitFunctionAgentProvider.java
+++ b/core/src/main/java/com/datastax/oss/sga/impl/agents/ai/GenAIToolKitFunctionAgentProvider.java
@@ -251,8 +251,9 @@ protected Map<String, Object> computeAgentConfiguration(AgentConfiguration agent
 
     protected static <T> T requiredField(Map<String, Object> step, AgentConfiguration agentConfiguration, Map<String, Object> originalConfiguration, String name) {
         if (!originalConfiguration.containsKey(name)) {
-            throw new IllegalArgumentException("Missing required field " + name + " in agent definition, type=" + agentConfiguration.getType()
-                    + ", name="+agentConfiguration.getName());
+            throw new IllegalArgumentException(
+                    "Missing required field '" + name + "' in agent definition, type=" + agentConfiguration.getType()
+                            + ", name=" + agentConfiguration.getName() + ", id=" + agentConfiguration.getId());
         }
         Object value = originalConfiguration.get(name);
         step.put(name, value);
diff --git a/core/src/main/java/com/datastax/oss/sga/impl/common/BasicClusterRuntime.java b/core/src/main/java/com/datastax/oss/sga/impl/common/BasicClusterRuntime.java
index 61b4a77a5..2958f3ceb 100644
--- a/core/src/main/java/com/datastax/oss/sga/impl/common/BasicClusterRuntime.java
+++ b/core/src/main/java/com/datastax/oss/sga/impl/common/BasicClusterRuntime.java
@@ -34,6 +34,7 @@
 
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
 
 /**
  * Basic class with common utility methods for a ClusterRuntime.
@@ -48,6 +49,10 @@ public ExecutionPlan buildExecutionPlan(String applicationId,
                                             Application application,
                                             PluginsRegistry pluginsRegistry, StreamingClusterRuntime streamingClusterRuntime) {
 
+        if (StringUtils.isEmpty(applicationId)) {
+            throw new IllegalArgumentException("Application id cannot be empty");
+        }
+
         ExecutionPlan result =
                 new ExecutionPlan(applicationId, application);
 
@@ -55,10 +60,14 @@ public ExecutionPlan buildExecutionPlan(String applicationId,
 
         detectAgents(result, streamingClusterRuntime, pluginsRegistry);
 
+        validateExecutionPlan(result, streamingClusterRuntime);
 
         return result;
     }
 
+    protected void validateExecutionPlan(ExecutionPlan plan, StreamingClusterRuntime streamingClusterRuntime) throws IllegalArgumentException {
+    }
+
     /**
      * Detects topics that are explicitly defined in the application instance.
      * @param result
diff --git a/core/src/main/java/com/datastax/oss/sga/impl/deploy/ApplicationDeployer.java b/core/src/main/java/com/datastax/oss/sga/impl/deploy/ApplicationDeployer.java
index 55fd148b5..09e0b2c0f 100644
--- a/core/src/main/java/com/datastax/oss/sga/impl/deploy/ApplicationDeployer.java
+++ b/core/src/main/java/com/datastax/oss/sga/impl/deploy/ApplicationDeployer.java
@@ -22,6 +22,7 @@
 import com.datastax.oss.sga.api.runtime.ClusterRuntimeRegistry;
 import com.datastax.oss.sga.api.runtime.StreamingClusterRuntime;
 import com.datastax.oss.sga.impl.common.ApplicationPlaceholderResolver;
+import java.util.Objects;
 import lombok.Builder;
 import lombok.extern.slf4j.Slf4j;
 
diff --git a/core/src/main/java/com/datastax/oss/sga/impl/parser/ModelBuilder.java b/core/src/main/java/com/datastax/oss/sga/impl/parser/ModelBuilder.java
index fd91379ed..3d964df9a 100644
--- a/core/src/main/java/com/datastax/oss/sga/impl/parser/ModelBuilder.java
+++ b/core/src/main/java/com/datastax/oss/sga/impl/parser/ModelBuilder.java
@@ -248,6 +248,9 @@ private static void validateGatewayKeyValueComparison(Gateway.KeyValueComparison
 
     private static void parsePipelineFile(String filename, String content, Application application) throws IOException {
         PipelineFileModel pipelineConfiguration = mapper.readValue(content, PipelineFileModel.class);
+        if (pipelineConfiguration.getModule() == null) {
+            pipelineConfiguration.setModule(Module.DEFAULT_MODULE);
+        }
         Module module = application.getModule(pipelineConfiguration.getModule());
         log.info("Configuration: {}", pipelineConfiguration);
         String id = pipelineConfiguration.getId();
@@ -289,9 +292,9 @@ private static void parsePipelineFile(String filename, String content, Applicati
                     if (Objects.equals(Module.DEFAULT_MODULE, module.getId())) {
                         moduleAutoId = "";
                     } else {
-                        moduleAutoId = module.getId() + "_";
+                        moduleAutoId = module.getId() + "-";
                     }
-                    String autoIdStr = moduleAutoId + pipeline.getId() + "_" + agentConfiguration.getType() + "_" + autoId;
+                    String autoIdStr = moduleAutoId + pipeline.getId() + "-" + agentConfiguration.getType() + "-" + autoId;
                     agentConfiguration.setId(autoIdStr);
                     autoId++;
                 }
diff --git a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/CRDConstants.java b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/CRDConstants.java
index 688d826ab..565e486bb 100644
--- a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/CRDConstants.java
+++ b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/CRDConstants.java
@@ -15,6 +15,8 @@
  */
 package com.datastax.oss.sga.deployer.k8s;
 
+import java.util.regex.Pattern;
+
 public class CRDConstants {
 
     public static final String COMMON_LABEL_APP = "app";
@@ -25,4 +27,23 @@ public class CRDConstants {
     public static final String AGENT_LABEL_APPLICATION = "sga-application";
     public static final String AGENT_LABEL_AGENT_ID = "sga-agent";
 
+
+    public static final String JOB_PREFIX_CLEANUP = "sga-runtime-deployer-cleanup-";
+    public static final String JOB_PREFIX_DEPLOYER = "sga-runtime-deployer-";
+
+    // 63 is the max length for a pod.
+    // - 30 for the prefix
+    // - 11 for the pod template hash + separator
+    // - 20 for the sum of application-id and agent-id
+    // - 1+1 for a minimal 1-char agent
+    public static final int MAX_APPLICATION_ID_LENGTH = 20;
+
+    // 63 is the max length for a pod.
+    // - 20 for the application id
+    // - 1 for the separator
+    // - 5 for the statefulset replicas index (up to replica n. 9999)
+    public static final int MAX_AGENT_ID_LENGTH = 37;
+
+    public static final Pattern RESOURCE_NAME_PATTERN = Pattern.compile("^([a-z])[-a-z0-9]+$");
+
 }
diff --git a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/agents/AgentResourcesFactory.java b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/agents/AgentResourcesFactory.java
index b8e3cf878..37e23a9d8 100644
--- a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/agents/AgentResourcesFactory.java
+++ b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/agents/AgentResourcesFactory.java
@@ -15,6 +15,7 @@
  */
 package com.datastax.oss.sga.deployer.k8s.agents;
 
+import static com.datastax.oss.sga.deployer.k8s.CRDConstants.MAX_AGENT_ID_LENGTH;
 import com.datastax.oss.sga.api.model.ApplicationStatus;
 import com.datastax.oss.sga.deployer.k8s.CRDConstants;
 import com.datastax.oss.sga.deployer.k8s.api.crds.agents.AgentCustomResource;
@@ -58,8 +59,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -151,7 +150,7 @@ public static StatefulSet generateStatefulSet(AgentCustomResource agentCustomRes
 
         final Map<String, String> labels = getAgentLabels(spec.getAgentId(), spec.getApplicationId());
 
-        String name = sanitizeName(agentCustomResource.getMetadata().getName());
+        final String name = agentCustomResource.getMetadata().getName();
         final StatefulSet statefulSet = new StatefulSetBuilder()
                 .withNewMetadata()
                 .withName(name)
@@ -290,8 +289,7 @@ public static AgentCustomResource generateAgentCustomResource(final String appli
     }
 
     public static String getAgentCustomResourceName(String applicationId, String agentId) {
-        final String agentName = "%s-%s".formatted(applicationId, agentId);
-        return sanitizeName(agentName);
+        return "%s-%s".formatted(applicationId, agentId);
     }
 
 
@@ -389,24 +387,7 @@ private static Map<String, ApplicationStatus.AgentWorkerStatus> getPodStatuses(
                 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
     }
 
-    public static String sanitizeName(String name) {
-        // Define the regular expression pattern
-        String pattern = "[^a-z0-9.-]";
 
-        // Remove invalid characters from the name
-        String sanitizedName = name.toLowerCase().replaceAll(pattern, "");
-
-        // Check if the sanitized name starts or ends with a non-alphanumeric character
-        if (!sanitizedName.matches("[a-z0-9].*[a-z0-9]")) {
-            // Add a default prefix and suffix
-            sanitizedName = "default-" + sanitizedName + "-default";
-        }
-
-        // Truncate the name to 63 characters
-        sanitizedName = sanitizedName.substring(0, Math.min(sanitizedName.length(), 63));
-
-        return sanitizedName;
-    }
 
 
     public static List<String> getAgentPods(KubernetesClient client, String namespace, String applicationId) {
@@ -424,4 +405,16 @@ public static List<String> getAgentPods(KubernetesClient client, String namespac
                 .collect(Collectors.toList());
     }
 
+    public static void validateAgentId(String agentId, String applicationId) throws IllegalArgumentException{
+        final String fullAgentId = getAgentCustomResourceName(applicationId, agentId);
+        if (!CRDConstants.RESOURCE_NAME_PATTERN.matcher(fullAgentId).matches()) {
+            throw new IllegalArgumentException(("Agent id '%s' (computed as '%s') contains illegal characters. "
+                    + "Allowed characters are alphanumeric and dash. To fully control the agent id, you can set the 'id' field.").formatted(agentId, fullAgentId));
+        }
+        if (agentId.length() > MAX_AGENT_ID_LENGTH) {
+            throw new IllegalArgumentException("Agent id '%s' is too long, max length is %d. To fully control the agent id, you can set the 'id' field.".formatted(agentId,
+                    MAX_AGENT_ID_LENGTH));
+        }
+    }
+
 }
diff --git a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/apps/AppResourcesFactory.java b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/apps/AppResourcesFactory.java
index dccdc9d50..e3ca43561 100644
--- a/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/apps/AppResourcesFactory.java
+++ b/k8s-deployer/k8s-deployer-core/src/main/java/com/datastax/oss/sga/deployer/k8s/apps/AppResourcesFactory.java
@@ -15,6 +15,9 @@
  */
 package com.datastax.oss.sga.deployer.k8s.apps;
 
+import static com.datastax.oss.sga.deployer.k8s.CRDConstants.JOB_PREFIX_CLEANUP;
+import static com.datastax.oss.sga.deployer.k8s.CRDConstants.JOB_PREFIX_DEPLOYER;
+import static com.datastax.oss.sga.deployer.k8s.CRDConstants.MAX_APPLICATION_ID_LENGTH;
 import com.datastax.oss.sga.api.model.ApplicationLifecycleStatus;
 import com.datastax.oss.sga.deployer.k8s.CRDConstants;
 import com.datastax.oss.sga.deployer.k8s.api.crds.apps.ApplicationCustomResource;
@@ -34,12 +37,12 @@
 import io.fabric8.kubernetes.client.KubernetesClient;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 import lombok.SneakyThrows;
 
 public class AppResourcesFactory {
 
 
+
     @SneakyThrows
     public static Job generateJob(ApplicationCustomResource applicationCustomResource, final Map<String, Object> clusterRuntimeConfiguration, boolean delete) {
 
@@ -151,9 +154,9 @@ public static Map<String, String> getLabels(boolean delete, String applicationId
 
     public static String getJobName(String applicationId, boolean delete) {
         if (delete) {
-            return "sga-runtime-deployer-cleanup-" + applicationId;
+            return JOB_PREFIX_CLEANUP + applicationId;
         } else {
-            return "sga-runtime-deployer-" + applicationId;
+            return JOB_PREFIX_DEPLOYER + applicationId;
         }
     }
 
@@ -199,4 +202,16 @@ private static ApplicationLifecycleStatus getStatusFromJob(KubernetesClient clie
     }
 
 
+    public static void validateApplicationId(String applicationId) throws IllegalArgumentException {
+        if (!CRDConstants.RESOURCE_NAME_PATTERN.matcher(applicationId).matches()) {
+            throw new IllegalArgumentException(("Application id '%s' contains illegal characters. Allowed characters are alphanumeric and "
+                    + "dash.").formatted(applicationId));
+        }
+
+        if (applicationId.length() > MAX_APPLICATION_ID_LENGTH) {
+            throw new IllegalArgumentException("Application id '%s' is too long, max length is %d".formatted(applicationId, MAX_APPLICATION_ID_LENGTH));
+        }
+    }
+
+
 }
diff --git a/k8s-runtime/k8s-runtime-core/src/main/java/com/datastax/oss/sga/runtime/impl/k8s/KubernetesClusterRuntime.java b/k8s-runtime/k8s-runtime-core/src/main/java/com/datastax/oss/sga/runtime/impl/k8s/KubernetesClusterRuntime.java
index 5ba6bc2a8..11e9a8250 100644
--- a/k8s-runtime/k8s-runtime-core/src/main/java/com/datastax/oss/sga/runtime/impl/k8s/KubernetesClusterRuntime.java
+++ b/k8s-runtime/k8s-runtime-core/src/main/java/com/datastax/oss/sga/runtime/impl/k8s/KubernetesClusterRuntime.java
@@ -23,6 +23,7 @@
 import com.datastax.oss.sga.deployer.k8s.agents.AgentResourcesFactory;
 import com.datastax.oss.sga.deployer.k8s.api.crds.agents.AgentCustomResource;
 import com.datastax.oss.sga.deployer.k8s.api.crds.agents.AgentSpec;
+import com.datastax.oss.sga.deployer.k8s.apps.AppResourcesFactory;
 import com.datastax.oss.sga.deployer.k8s.util.SerializationUtil;
 import com.datastax.oss.sga.impl.agents.ai.GenAIToolKitExecutionPlanOptimizer;
 import com.datastax.oss.sga.impl.common.BasicClusterRuntime;
@@ -211,6 +212,17 @@ private static String bytesToHex(byte[] hash) {
         return hexString.toString();
     }
 
+    @Override
+    protected void validateExecutionPlan(ExecutionPlan plan, StreamingClusterRuntime streamingClusterRuntime)
+            throws IllegalArgumentException {
+        super.validateExecutionPlan(plan, streamingClusterRuntime);
+        AppResourcesFactory.validateApplicationId(plan.getApplicationId());
+        final String applicationId = plan.getApplicationId();
+        for (AgentNode agentNode : plan.getAgents().values()) {
+            AgentResourcesFactory.validateAgentId(agentNode.getId(), applicationId);
+        }
+    }
+
     @Override
     public void delete(String tenant, ExecutionPlan applicationInstance,
                        StreamingClusterRuntime streamingClusterRuntime, String codeStorageArchiveId) {
diff --git a/webservice/src/main/java/com/datastax/oss/sga/webservice/application/ApplicationService.java b/webservice/src/main/java/com/datastax/oss/sga/webservice/application/ApplicationService.java
index 71a512294..8e085281c 100644
--- a/webservice/src/main/java/com/datastax/oss/sga/webservice/application/ApplicationService.java
+++ b/webservice/src/main/java/com/datastax/oss/sga/webservice/application/ApplicationService.java
@@ -143,7 +143,7 @@ private Application validateDeployAndMerge(String tenant, String applicationId,
 
     }
 
-    private ExecutionPlan validateExecutionPlan(String applicationId, Application applicationInstance) {
+    ExecutionPlan validateExecutionPlan(String applicationId, Application applicationInstance) {
         final ExecutionPlan newPlan =
                 deployer.createImplementation(applicationId, applicationInstance);
         return newPlan;


Test patch:
diff --git a/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/ComputeEmbeddingsTest.java b/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/ComputeEmbeddingsTest.java
index f32670a62..f60f0e29d 100644
--- a/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/ComputeEmbeddingsTest.java
+++ b/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/ComputeEmbeddingsTest.java
@@ -138,7 +138,7 @@ public void testComputeEmbeddings(EmbeddingsConfig config) throws Exception {
         // wait for WireMock to be ready
         Thread.sleep(1000);
 
-        final String appId = "application-" + UUID.randomUUID();
+        final String appId = "app-" + UUID.randomUUID().toString().substring(0, 4);
         String inputTopic = "input-topic-" + UUID.randomUUID();
         String outputTopic = "output-topic-" + UUID.randomUUID();
         String tenant = "tenant";
diff --git a/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/TextProcessingAgentsRunnerTest.java b/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/TextProcessingAgentsRunnerTest.java
index 67bad55e7..7b34af586 100644
--- a/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/TextProcessingAgentsRunnerTest.java
+++ b/runtime/runtime-impl/src/test/java/com/datastax/oss/sga/kafka/TextProcessingAgentsRunnerTest.java
@@ -29,7 +29,7 @@ class TextProcessingAgentsRunnerTest extends AbstractApplicationRunner {
     @Test
     public void testFullLanguageProcessingPipeline() throws Exception {
         String tenant = "tenant";
-        String[] expectedAgents = {"app-module-1pipeline-1text-extractor1"};
+        String[] expectedAgents = {"app-module-1-pipeline-1-text-extractor-1"};
 
         Map<String, String> application = Map.of("instance.yaml",
                         buildInstanceYaml(),
diff --git a/webservice/src/test/java/com/datastax/oss/sga/webservice/application/ApplicationServiceValidateAppTest.java b/webservice/src/test/java/com/datastax/oss/sga/webservice/application/ApplicationServiceValidateAppTest.java
new file mode 100644
index 000000000..c7fc0b11d
--- /dev/null
+++ b/webservice/src/test/java/com/datastax/oss/sga/webservice/application/ApplicationServiceValidateAppTest.java
@@ -0,0 +1,154 @@
+/**
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.sga.webservice.application;
+
+import com.datastax.oss.sga.api.model.Application;
+import com.datastax.oss.sga.api.model.ResourcesSpec;
+import com.datastax.oss.sga.api.model.SchemaDefinition;
+import com.datastax.oss.sga.api.model.TopicDefinition;
+import com.datastax.oss.sga.api.runtime.ClusterRuntimeRegistry;
+import com.datastax.oss.sga.api.runtime.ExecutionPlan;
+import com.datastax.oss.sga.api.runtime.PluginsRegistry;
+import com.datastax.oss.sga.deployer.k8s.util.SerializationUtil;
+import com.datastax.oss.sga.impl.deploy.ApplicationDeployer;
+import com.datastax.oss.sga.impl.k8s.tests.KubeK3sServer;
+import com.datastax.oss.sga.impl.k8s.tests.KubeTestServer;
+import com.datastax.oss.sga.impl.parser.ModelBuilder;
+import com.datastax.oss.sga.webservice.config.ApplicationDeployProperties;
+import java.util.List;
+import java.util.Map;
+import lombok.Cleanup;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+@Slf4j
+class ApplicationServiceValidateAppTest {
+
+    @RegisterExtension
+    static final KubeTestServer k3s = new KubeTestServer();
+
+    @Test
+    void testApplicationId() throws Exception {
+        final Map<String, String> files = filesWithOneAgent(null, null, "s");
+        validate(null, files, false);
+        validate("", files, false);
+        validate("myapp", files, true);
+        validate("all-chars09", files, true);
+        validate("myapp with spaces", files, false);
+        validate("myapp_", files, false);
+        validate("9myapp", files, false);
+        validate("Umyapp", files, false);
+        validate("a".repeat(20), files, true);
+        validate("a".repeat(21), files, false);
+    }
+
+    @Test
+    void testAgentWithFixedId() throws Exception {
+        final String appId = "app";
+        validate(appId, filesWithOneAgent(null, null, "agent"), true);
+        validate(appId, filesWithOneAgent(null, null, "agent01-"), true);
+        validate(appId, filesWithOneAgent(null, null, "a".repeat(37)), true);
+        validate(appId, filesWithOneAgent(null, null, "a".repeat(38)), false);
+        validate(appId, filesWithOneAgent(null, null, "with spaces"), false);
+        validate(appId, filesWithOneAgent(null, null, "Upper"), false);
+        validate(appId, filesWithOneAgent(null, null, "0agent"), true);
+        validate(appId, filesWithOneAgent(null, null, "0"), true);
+    }
+
+    @Test
+    void testAgentWithComputedId() throws Exception {
+        final String appId = "app";
+        validate(appId, filesWithOneAgent(null, null, null), true);
+        validate(appId, filesWithOneAgent("m".repeat(21), null, null), true);
+        validate(appId, filesWithOneAgent("m".repeat(24), null, null), false);
+
+        validate(appId, filesWithOneAgent("with spaces", null, null), false);
+        validate(appId, filesWithOneAgent("withUpper", null, null), false);
+    }
+
+    private Map<String, String> filesWithOneAgent(String module, String pipeline, String agentId) {
+        if (pipeline == null) {
+            pipeline = "pipeline";
+        }
+        final Map<String, String> files = Map.of("instance.yaml",
+                """
+                        instance:
+                          streamingCluster:
+                            type: "noop"
+                          computeCluster:
+                            type: "kubernetes"
+                        """,
+                "%s.yaml".formatted(pipeline), """
+                        module: %s
+                        id: %s
+                        topics:
+                          - name: "input-topic"
+                            creation-mode: create-if-not-exists
+                          - name: "output-topic"
+                            creation-mode: create-if-not-exists
+                        pipeline:
+                          - id: %s
+                            type: "drop"
+                            input: "input-topic"
+                            output: "output-topic"                         
+                        """.formatted(module, pipeline, agentId));
+        return files;
+    }
+
+    @SneakyThrows
+    private static void validate(String applicationId, Map<String, String> files, boolean expectValid) {
+        final ApplicationService service = getApplicationService();
+        final Application application = ModelBuilder.buildApplicationInstance(files);
+        boolean ok = false;
+        Throwable exception = null;
+        try {
+            final ExecutionPlan plan = service.validateExecutionPlan(
+                    applicationId,
+                    application
+            );
+            log.info("Got agents: {}", plan.getAgents().values().stream().map(a -> a.getId()).toList());
+            if (expectValid) {
+                ok = true;
+            }
+
+        } catch (Exception e) {
+            if (!expectValid) {
+                ok = true;
+                log.info("Got expected exception", e);
+            }
+            exception = e;
+        }
+        if (!ok) {
+            if (!expectValid) {
+                throw new RuntimeException("Expected exception");
+            } else {
+                throw new RuntimeException("Expected app to be valid. Instead got: " +  exception.getMessage());
+            }
+        }
+
+    }
+
+    @NotNull
+    private static ApplicationService getApplicationService() {
+        final ApplicationService service = new ApplicationService(null, null, new ApplicationDeployProperties(
+                new ApplicationDeployProperties.GatewayProperties(false)));
+        return service;
+    }
+
+}