Repository: LangStream/langstream (language: python)

Issue:
Cli: add command to resolve placeholders locally 
It could be very useful to have a CLI command that resolves all the placeholder, for debugging purposes. It would be similar to [Helm template](https://helm.sh/docs/helm/helm_template/).

The output would be sent to stdout.





Discussion / hints:
The command should just call the control plane.
The reason to perform the placeholder parsing+resolving on the server are:
* CLI doesn't import langstream-core for several reasons (jdk11, lightweight package..)
* Control plane is the actual resolver at runtime and the available features may vary depending on the version. In this way the result is accurate compared to the deploy command

We could call this feature `dry-run`. 

```
$ langstream apps deploy test --dry-run -app ...
<resolved json> 
```
The output could look like helm, dumping al the manifests

Fix patch:
diff --git a/langstream-admin-client/src/main/java/ai/langstream/admin/client/AdminClient.java b/langstream-admin-client/src/main/java/ai/langstream/admin/client/AdminClient.java
index fcf3dd679..128ccd833 100644
--- a/langstream-admin-client/src/main/java/ai/langstream/admin/client/AdminClient.java
+++ b/langstream-admin-client/src/main/java/ai/langstream/admin/client/AdminClient.java
@@ -181,16 +181,22 @@ public Applications applications() {
     }
 
     private class ApplicationsImpl implements Applications {
+        @Override
+        public String deploy(String application, MultiPartBodyPublisher multiPartBodyPublisher) {
+            return deploy(application, multiPartBodyPublisher, false);
+        }
+
         @Override
         @SneakyThrows
-        public void deploy(String application, MultiPartBodyPublisher multiPartBodyPublisher) {
-            final String path = tenantAppPath("/" + application);
+        public String deploy(
+                String application, MultiPartBodyPublisher multiPartBodyPublisher, boolean dryRun) {
+            final String path = tenantAppPath("/" + application) + "?dry-run=" + dryRun;
             final String contentType =
                     String.format(
                             "multipart/form-data; boundary=%s",
                             multiPartBodyPublisher.getBoundary());
             final HttpRequest request = newPost(path, contentType, multiPartBodyPublisher.build());
-            http(request);
+            return http(request).body();
         }
 
         @Override
diff --git a/langstream-admin-client/src/main/java/ai/langstream/admin/client/model/Applications.java b/langstream-admin-client/src/main/java/ai/langstream/admin/client/model/Applications.java
index 9caa59d5e..5e39063ac 100644
--- a/langstream-admin-client/src/main/java/ai/langstream/admin/client/model/Applications.java
+++ b/langstream-admin-client/src/main/java/ai/langstream/admin/client/model/Applications.java
@@ -19,7 +19,10 @@
 import java.net.http.HttpResponse;
 
 public interface Applications {
-    void deploy(String application, MultiPartBodyPublisher multiPartBodyPublisher);
+    String deploy(String application, MultiPartBodyPublisher multiPartBodyPublisher);
+
+    String deploy(
+            String application, MultiPartBodyPublisher multiPartBodyPublisher, boolean dryRun);
 
     void update(String application, MultiPartBodyPublisher multiPartBodyPublisher);
 
diff --git a/langstream-api/src/main/java/ai/langstream/api/webservice/application/ApplicationDescription.java b/langstream-api/src/main/java/ai/langstream/api/webservice/application/ApplicationDescription.java
index 417d8d344..9de988c31 100644
--- a/langstream-api/src/main/java/ai/langstream/api/webservice/application/ApplicationDescription.java
+++ b/langstream-api/src/main/java/ai/langstream/api/webservice/application/ApplicationDescription.java
@@ -64,7 +64,7 @@ public ApplicationDescription(
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public static class ApplicationDefinition {
 
-        private ApplicationDefinition(Application application) {
+        public ApplicationDefinition(Application application) {
             this.resources = application.getResources();
             this.modules =
                     application.getModules().values().stream().map(ModuleDefinition::new).toList();
diff --git a/langstream-cli/src/main/java/ai/langstream/cli/commands/applications/AbstractDeployApplicationCmd.java b/langstream-cli/src/main/java/ai/langstream/cli/commands/applications/AbstractDeployApplicationCmd.java
index 17893d0cd..ee2084b7a 100644
--- a/langstream-cli/src/main/java/ai/langstream/cli/commands/applications/AbstractDeployApplicationCmd.java
+++ b/langstream-cli/src/main/java/ai/langstream/cli/commands/applications/AbstractDeployApplicationCmd.java
@@ -55,6 +55,17 @@ public static class DeployApplicationCmd extends AbstractDeployApplicationCmd {
                 description = "Secrets file path")
         private String secretFilePath;
 
+        @CommandLine.Option(
+                names = {"--dry-run"},
+                description =
+                        "Dry-run mode. Do not deploy the application but only resolves placeholders and display the result.")
+        private boolean dryRun;
+
+        @CommandLine.Option(
+                names = {"-o"},
+                description = "Output format for dry-run mode.")
+        private Formats format = Formats.raw;
+
         @Override
         String applicationId() {
             return name;
@@ -79,6 +90,16 @@ String secretFilePath() {
         boolean isUpdate() {
             return false;
         }
+
+        @Override
+        boolean isDryRun() {
+            return dryRun;
+        }
+
+        @Override
+        Formats format() {
+            return format;
+        }
     }
 
     @CommandLine.Command(name = "update", header = "Update an existing LangStream application")
@@ -126,6 +147,16 @@ String secretFilePath() {
         boolean isUpdate() {
             return true;
         }
+
+        @Override
+        boolean isDryRun() {
+            return false;
+        }
+
+        @Override
+        Formats format() {
+            return null;
+        }
     }
 
     abstract String applicationId();
@@ -138,6 +169,10 @@ boolean isUpdate() {
 
     abstract boolean isUpdate();
 
+    abstract boolean isDryRun();
+
+    abstract Formats format();
+
     @Override
     @SneakyThrows
     public void run() {
@@ -160,9 +195,6 @@ public void run() {
         final Path tempZip = buildZip(appDirectory, this::log);
 
         long size = Files.size(tempZip);
-        log(String.format("deploying application: %s (%d KB)", applicationId, size / 1024));
-        String secretsContents = null;
-        String instanceContents = null;
 
         final Map<String, Object> contents = new HashMap<>();
         contents.put("app", tempZip);
@@ -170,9 +202,8 @@ public void run() {
             try {
                 contents.put(
                         "instance",
-                        instanceContents =
-                                LocalFileReferenceResolver.resolveFileReferencesInYAMLFile(
-                                        instanceFile.toPath()));
+                        LocalFileReferenceResolver.resolveFileReferencesInYAMLFile(
+                                instanceFile.toPath()));
             } catch (Exception e) {
                 log(
                         "Failed to resolve instance file references. Please double check the file path: "
@@ -185,9 +216,8 @@ public void run() {
             try {
                 contents.put(
                         "secrets",
-                        secretsContents =
-                                LocalFileReferenceResolver.resolveFileReferencesInYAMLFile(
-                                        secretsFile.toPath()));
+                        LocalFileReferenceResolver.resolveFileReferencesInYAMLFile(
+                                secretsFile.toPath()));
             } catch (Exception e) {
                 log(
                         "Failed to resolve secrets file references. Please double check the file path: "
@@ -199,11 +229,27 @@ public void run() {
         final MultiPartBodyPublisher bodyPublisher = buildMultipartContentForAppZip(contents);
 
         if (isUpdate()) {
+            log(String.format("updating application: %s (%d KB)", applicationId, size / 1024));
             getClient().applications().update(applicationId, bodyPublisher);
             log(String.format("application %s updated", applicationId));
         } else {
-            getClient().applications().deploy(applicationId, bodyPublisher);
-            log(String.format("application %s deployed", applicationId));
+            final boolean dryRun = isDryRun();
+            if (dryRun) {
+                log(
+                        String.format(
+                                "resolving application: %s. Dry run mode is enabled, the application will NOT be deployed",
+                                applicationId));
+            } else {
+                log(String.format("deploying application: %s (%d KB)", applicationId, size / 1024));
+            }
+            final String response =
+                    getClient().applications().deploy(applicationId, bodyPublisher, dryRun);
+            if (dryRun) {
+                final Formats format = format();
+                print(format == Formats.raw ? Formats.yaml : format, response, null, null);
+            } else {
+                log(String.format("application %s deployed", applicationId));
+            }
         }
     }
 
diff --git a/langstream-cli/src/main/java/ai/langstream/cli/commands/docker/LocalRunApplicationCmd.java b/langstream-cli/src/main/java/ai/langstream/cli/commands/docker/LocalRunApplicationCmd.java
index 3fbea9576..9687d2a49 100644
--- a/langstream-cli/src/main/java/ai/langstream/cli/commands/docker/LocalRunApplicationCmd.java
+++ b/langstream-cli/src/main/java/ai/langstream/cli/commands/docker/LocalRunApplicationCmd.java
@@ -102,6 +102,12 @@ public class LocalRunApplicationCmd extends BaseDockerCmd {
             description = "Docker image of the LangStream runtime to use")
     private String dockerImageName;
 
+    @CommandLine.Option(
+            names = {"--dry-run"},
+            description =
+                    "Dry-run mode. Do not deploy the application but only resolves placeholders and display the result.")
+    private boolean dryRun;
+
     @Override
     @SneakyThrows
     public void run() {
@@ -120,6 +126,10 @@ public void run() {
                 dockerImageName = "ghcr.io/langstream/langstream-runtime-tester";
             }
         }
+        startBroker = !dryRun && startBroker;
+        startDatabase = !dryRun && startDatabase;
+        startS3 = !dryRun && startS3;
+        startWebservices = !dryRun && startWebservices;
 
         final File appDirectory = checkFileExistsOrDownload(appPath);
         final File instanceFile;
@@ -170,7 +180,7 @@ public void run() {
                 throw e;
             }
         } else {
-            if (startBroker) {
+            if (startBroker || dryRun) {
                 instanceContents =
                         "instance:\n"
                                 + "  streamingCluster:\n"
@@ -211,7 +221,8 @@ public void run() {
                 startBroker,
                 startS3,
                 startWebservices,
-                startDatabase);
+                startDatabase,
+                dryRun);
     }
 
     private void executeOnDocker(
@@ -224,7 +235,8 @@ private void executeOnDocker(
             boolean startBroker,
             boolean startS3,
             boolean startWebservices,
-            boolean startDatabase)
+            boolean startDatabase,
+            boolean dryRun)
             throws Exception {
         File tmpInstanceFile = Files.createTempFile("instance", ".yaml").toFile();
         Files.write(tmpInstanceFile.toPath(), instanceContents.getBytes(StandardCharsets.UTF_8));
@@ -251,6 +263,8 @@ private void executeOnDocker(
         commandLine.add("LANSGSTREAM_TESTER_APPLICATIONID=" + applicationId);
         commandLine.add("-e");
         commandLine.add("LANSGSTREAM_TESTER_STARTWEBSERVICES=" + startWebservices);
+        commandLine.add("-e");
+        commandLine.add("LANSGSTREAM_TESTER_DRYRUN=" + dryRun);
 
         if (singleAgentId != null && !singleAgentId.isEmpty()) {
             commandLine.add("-e");
diff --git a/langstream-webservice/src/main/java/ai/langstream/webservice/application/ApplicationResource.java b/langstream-webservice/src/main/java/ai/langstream/webservice/application/ApplicationResource.java
index ac7b2b1c7..e8a4d1208 100644
--- a/langstream-webservice/src/main/java/ai/langstream/webservice/application/ApplicationResource.java
+++ b/langstream-webservice/src/main/java/ai/langstream/webservice/application/ApplicationResource.java
@@ -16,11 +16,13 @@
 package ai.langstream.webservice.application;
 
 import ai.langstream.api.codestorage.CodeStorageException;
+import ai.langstream.api.model.Application;
 import ai.langstream.api.model.ApplicationSpecs;
 import ai.langstream.api.model.StoredApplication;
 import ai.langstream.api.storage.ApplicationStore;
 import ai.langstream.api.webservice.application.ApplicationCodeInfo;
 import ai.langstream.api.webservice.application.ApplicationDescription;
+import ai.langstream.impl.common.ApplicationPlaceholderResolver;
 import ai.langstream.impl.parser.ModelBuilder;
 import ai.langstream.webservice.security.infrastructure.primary.TokenAuthFilter;
 import io.swagger.v3.oas.annotations.Operation;
@@ -130,13 +132,14 @@ Collection<ApplicationDescription> getApplications(
 
     @PostMapping(value = "/{tenant}/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
     @Operation(summary = "Create and deploy an application")
-    void deployApplication(
+    ApplicationDescription.ApplicationDefinition deployApplication(
             Authentication authentication,
             @NotBlank @PathVariable("tenant") String tenant,
             @NotBlank @PathVariable("id") String applicationId,
             @RequestParam("app") MultipartFile appFile,
             @RequestParam String instance,
-            @RequestParam Optional<String> secrets)
+            @RequestParam Optional<String> secrets,
+            @RequestParam(value = "dry-run", required = false) boolean dryRun)
             throws Exception {
         performAuthorization(authentication, tenant);
         final ParsedApplication parsedApplication =
@@ -145,12 +148,23 @@ void deployApplication(
                         Optional.of(appFile),
                         Optional.of(instance),
                         secrets,
-                        tenant);
-        applicationService.deployApplication(
-                tenant,
-                applicationId,
-                parsedApplication.getApplication(),
-                parsedApplication.getCodeArchiveReference());
+                        tenant,
+                        dryRun);
+        final Application application;
+        if (dryRun) {
+            application =
+                    ApplicationPlaceholderResolver.resolvePlaceholders(
+                            parsedApplication.getApplication().getApplication());
+
+        } else {
+            applicationService.deployApplication(
+                    tenant,
+                    applicationId,
+                    parsedApplication.getApplication(),
+                    parsedApplication.getCodeArchiveReference());
+            application = parsedApplication.getApplication().getApplication();
+        }
+        return new ApplicationDescription.ApplicationDefinition(application);
     }
 
     @PatchMapping(value = "/{tenant}/{id}", consumes = "multipart/form-data")
@@ -165,7 +179,7 @@ void updateApplication(
             throws Exception {
         performAuthorization(authentication, tenant);
         final ParsedApplication parsedApplication =
-                parseApplicationInstance(applicationId, appFile, instance, secrets, tenant);
+                parseApplicationInstance(applicationId, appFile, instance, secrets, tenant, false);
         applicationService.updateApplication(
                 tenant,
                 applicationId,
@@ -184,7 +198,8 @@ private ParsedApplication parseApplicationInstance(
             Optional<MultipartFile> file,
             Optional<String> instance,
             Optional<String> secrets,
-            String tenant)
+            String tenant,
+            boolean dryRun)
             throws Exception {
         final ParsedApplication parsedApplication = new ParsedApplication();
         withApplicationZip(
@@ -197,7 +212,7 @@ private ParsedApplication parseApplicationInstance(
                                         instance.orElse(null),
                                         secrets.orElse(null));
                         final String codeArchiveReference;
-                        if (zip == null) {
+                        if (zip == null || dryRun) {
                             codeArchiveReference = null;
                         } else {
                             codeArchiveReference =


Test patch:
diff --git a/langstream-cli/src/test/java/ai/langstream/cli/commands/applications/AppsCmdTest.java b/langstream-cli/src/test/java/ai/langstream/cli/commands/applications/AppsCmdTest.java
index 4601a54bf..939f00b75 100644
--- a/langstream-cli/src/test/java/ai/langstream/cli/commands/applications/AppsCmdTest.java
+++ b/langstream-cli/src/test/java/ai/langstream/cli/commands/applications/AppsCmdTest.java
@@ -48,7 +48,7 @@ public void testDeploy() throws Exception {
                 AbstractDeployApplicationCmd.buildZip(langstream.toFile(), System.out::println);
 
         wireMock.register(
-                WireMock.post(String.format("/api/applications/%s/my-app", TENANT))
+                WireMock.post(String.format("/api/applications/%s/my-app?dry-run=false", TENANT))
                         .withMultipartRequestBody(
                                 aMultipart("app")
                                         .withBody(binaryEqualTo(Files.readAllBytes(zipFile))))
@@ -108,7 +108,7 @@ public void testDeployWithDependencies() throws Exception {
         final Path zipFile =
                 AbstractDeployApplicationCmd.buildZip(langstream.toFile(), System.out::println);
         wireMock.register(
-                WireMock.post(String.format("/api/applications/%s/my-app", TENANT))
+                WireMock.post(String.format("/api/applications/%s/my-app?dry-run=false", TENANT))
                         .withMultipartRequestBody(
                                 aMultipart("app")
                                         .withBody(binaryEqualTo(Files.readAllBytes(zipFile))))
@@ -168,6 +168,62 @@ public void testUpdateAll() throws Exception {
         Assertions.assertEquals("", result.err());
     }
 
+    @Test
+    public void testDeployDryRun() throws Exception {
+        Path langstream = Files.createTempDirectory("langstream");
+        final String app = createTempFile("module: module-1", langstream);
+        final String instance = createTempFile("instance: {}");
+        final String secrets = createTempFile("secrets: []");
+
+        final Path zipFile =
+                AbstractDeployApplicationCmd.buildZip(langstream.toFile(), System.out::println);
+
+        wireMock.register(
+                WireMock.post(String.format("/api/applications/%s/my-app?dry-run=true", TENANT))
+                        .withMultipartRequestBody(
+                                aMultipart("app")
+                                        .withBody(binaryEqualTo(Files.readAllBytes(zipFile))))
+                        .withMultipartRequestBody(
+                                aMultipart("instance").withBody(equalTo("instance: {}")))
+                        .withMultipartRequestBody(
+                                aMultipart("secrets").withBody(equalTo("secrets: []")))
+                        .willReturn(WireMock.ok("{ \"name\": \"my-app\" }")));
+
+        CommandResult result =
+                executeCommand(
+                        "apps",
+                        "deploy",
+                        "my-app",
+                        "-s",
+                        secrets,
+                        "-app",
+                        langstream.toAbsolutePath().toString(),
+                        "-i",
+                        instance,
+                        "--dry-run");
+        Assertions.assertEquals(0, result.exitCode());
+        Assertions.assertEquals("", result.err());
+        Assertions.assertTrue(result.out().contains("name: \"my-app\""));
+
+        result =
+                executeCommand(
+                        "apps",
+                        "deploy",
+                        "my-app",
+                        "-s",
+                        secrets,
+                        "-app",
+                        langstream.toAbsolutePath().toString(),
+                        "-i",
+                        instance,
+                        "--dry-run",
+                        "-o",
+                        "json");
+        Assertions.assertEquals(0, result.exitCode());
+        Assertions.assertEquals("", result.err());
+        Assertions.assertTrue(result.out().contains("{\n" + "  \"name\" : \"my-app\"\n" + "}"));
+    }
+
     @Test
     public void testUpdateInstance() throws Exception {
         final String instance = createTempFile("instance: {}");
@@ -399,7 +455,7 @@ public void testDeployWithFilePlaceholders() throws Exception {
                 AbstractDeployApplicationCmd.buildZip(langstream.toFile(), System.out::println);
 
         wireMock.register(
-                WireMock.post(String.format("/api/applications/%s/my-app", TENANT))
+                WireMock.post(String.format("/api/applications/%s/my-app?dry-run=false", TENANT))
                         .withMultipartRequestBody(
                                 aMultipart("app")
                                         .withBody(binaryEqualTo(Files.readAllBytes(zipFile))))
diff --git a/langstream-runtime/langstream-runtime-tester/src/main/assemble/entrypoint.sh b/langstream-runtime/langstream-runtime-tester/src/main/assemble/entrypoint.sh
index 38638980d..f2bd5042c 100644
--- a/langstream-runtime/langstream-runtime-tester/src/main/assemble/entrypoint.sh
+++ b/langstream-runtime/langstream-runtime-tester/src/main/assemble/entrypoint.sh
@@ -33,4 +33,4 @@ if [ "$START_HERDDB" = "true" ]; then
   /herddb/herddb/bin/service server start
 fi
 
-exec java ${JAVA_OPTS} -Dlogging.config=/app/logback.xml -Djdk.lang.Process.launchMechanism=vfork -cp "/app/lib/*:/app/tester/lib/*" "ai.langstream.runtime.tester.Main"
+exec java ${JAVA_OPTS} -D -Dlogback.configurationFile=/app/logback.xml -Djdk.lang.Process.launchMechanism=vfork -cp "/app/lib/*" "ai.langstream.runtime.tester.Main"
diff --git a/langstream-runtime/langstream-runtime-tester/src/main/docker/Dockerfile b/langstream-runtime/langstream-runtime-tester/src/main/docker/Dockerfile
index d190fcf9c..7e00a4708 100644
--- a/langstream-runtime/langstream-runtime-tester/src/main/docker/Dockerfile
+++ b/langstream-runtime/langstream-runtime-tester/src/main/docker/Dockerfile
@@ -41,8 +41,9 @@ RUN chmod -R g+w /kafka \
        && chown 10000:0 -R /herddb
 
 # Add the runtime code at the end. This optimizes docker layers to not depend on artifacts-specific changes.
-ADD maven/lib /app/tester/lib
-RUN rm -f /app/tester/lib/*netty*
+RUN rm -rf /app/lib
+ADD maven/lib /app/lib
+RUN rm -f /app/lib/*netty*
 ADD maven/entrypoint.sh /app/entrypoint.sh
 ADD maven/logback.xml /app/logback.xml
 
diff --git a/langstream-runtime/langstream-runtime-tester/src/main/java/ai/langstream/runtime/tester/Main.java b/langstream-runtime/langstream-runtime-tester/src/main/java/ai/langstream/runtime/tester/Main.java
index d54d10ff9..11416ed44 100644
--- a/langstream-runtime/langstream-runtime-tester/src/main/java/ai/langstream/runtime/tester/Main.java
+++ b/langstream-runtime/langstream-runtime-tester/src/main/java/ai/langstream/runtime/tester/Main.java
@@ -15,9 +15,15 @@
  */
 package ai.langstream.runtime.tester;
 
+import ai.langstream.api.model.Application;
+import ai.langstream.api.webservice.application.ApplicationDescription;
 import ai.langstream.apigateway.LangStreamApiGateway;
+import ai.langstream.impl.common.ApplicationPlaceholderResolver;
 import ai.langstream.impl.parser.ModelBuilder;
 import ai.langstream.webservice.LangStreamControlPlaneWebApplication;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -40,6 +46,9 @@ public static void main(String... args) {
                             System.getenv()
                                     .getOrDefault("LANSGSTREAM_TESTER_STARTWEBSERVICES", "true"));
 
+            boolean dryRunMode =
+                    Boolean.parseBoolean(
+                            System.getenv().getOrDefault("LANSGSTREAM_TESTER_DRYRUN", "false"));
             String applicationPath = "/code/application";
             String instanceFile = "/code/instance.yaml";
             String secretsFile = "/code/secrets.yaml";
@@ -60,6 +69,19 @@ public static void main(String... args) {
                     ModelBuilder.buildApplicationInstance(
                             applicationDirectories, instance, secrets);
 
+            if (dryRunMode) {
+                log.info("Dry run mode");
+                final Application resolved =
+                        ApplicationPlaceholderResolver.resolvePlaceholders(
+                                applicationWithPackageInfo.getApplication());
+                final ApplicationDescription.ApplicationDefinition def =
+                        new ApplicationDescription.ApplicationDefinition(resolved);
+
+                final String asString = yamlPrinter().writeValueAsString(def);
+                log.info("Application:\n{}", asString);
+                return;
+            }
+
             List<String> expectedAgents = new ArrayList<>();
             List<String> allAgentIds = new ArrayList<>();
             applicationWithPackageInfo
@@ -145,4 +167,10 @@ public static void main(String... args) {
             error.printStackTrace();
         }
     }
+
+    private static ObjectMapper yamlPrinter() {
+        return new ObjectMapper(new YAMLFactory())
+                .enable(SerializationFeature.INDENT_OUTPUT)
+                .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
+    }
 }
diff --git a/langstream-webservice/src/test/java/ai/langstream/webservice/application/AppTestHelper.java b/langstream-webservice/src/test/java/ai/langstream/webservice/application/AppTestHelper.java
index 0be11b6f4..55018b693 100644
--- a/langstream-webservice/src/test/java/ai/langstream/webservice/application/AppTestHelper.java
+++ b/langstream-webservice/src/test/java/ai/langstream/webservice/application/AppTestHelper.java
@@ -23,6 +23,7 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Map;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.mock.web.MockMultipartFile;
@@ -125,10 +126,41 @@ public static ResultActions updateApp(
             String secretsContent,
             boolean checkOk)
             throws Exception {
+        return updateApp(
+                mockMvc,
+                patch,
+                tenant,
+                applicationId,
+                appFileContent,
+                instanceContent,
+                secretsContent,
+                checkOk,
+                null);
+    }
+
+    public static ResultActions updateApp(
+            MockMvc mockMvc,
+            boolean patch,
+            String tenant,
+            String applicationId,
+            String appFileContent,
+            String instanceContent,
+            String secretsContent,
+            boolean checkOk,
+            Map<String, String> queryString)
+            throws Exception {
+        final String queryStringStr =
+                queryString == null
+                        ? ""
+                        : queryString.entrySet().stream()
+                                .map(e -> e.getKey() + "=" + e.getValue())
+                                .reduce((a, b) -> a + "&" + b)
+                                .orElse("");
         final MockMultipartHttpServletRequestBuilder multipart =
                 multipart(
                         patch ? HttpMethod.PATCH : HttpMethod.POST,
-                        "/api/applications/%s/%s".formatted(tenant, applicationId));
+                        "/api/applications/%s/%s?%s"
+                                .formatted(tenant, applicationId, queryStringStr));
         if (appFileContent != null) {
             multipart.file(getMultipartFile(appFileContent));
         }
diff --git a/langstream-webservice/src/test/java/ai/langstream/webservice/application/ApplicationResourceTest.java b/langstream-webservice/src/test/java/ai/langstream/webservice/application/ApplicationResourceTest.java
index ea647d3e2..c56d012d3 100644
--- a/langstream-webservice/src/test/java/ai/langstream/webservice/application/ApplicationResourceTest.java
+++ b/langstream-webservice/src/test/java/ai/langstream/webservice/application/ApplicationResourceTest.java
@@ -20,6 +20,7 @@
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -27,6 +28,7 @@
 import ai.langstream.impl.k8s.tests.KubeK3sServer;
 import ai.langstream.webservice.WebAppTestConfig;
 import java.nio.file.Path;
+import java.util.Map;
 import lombok.extern.slf4j.Slf4j;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -290,4 +292,109 @@ void testTenantInDeletion() throws Exception {
         mockMvc.perform(get("/api/applications/my-tenant3/test"))
                 .andExpect(status().isInternalServerError());
     }
+
+    @Test
+    void testDeployDryRun() throws Exception {
+        mockMvc.perform(put("/api/tenants/my-tenant4")).andExpect(status().isOk());
+        AppTestHelper.updateApp(
+                        mockMvc,
+                        false,
+                        "my-tenant4",
+                        "test",
+                        """
+                        id: app1
+                        name: test
+                        topics:
+                          - name: "history-topic"
+                        pipeline:
+                            - name: "ai-chat-completions"
+                              type: "ai-chat-completions"
+                              output: "history-topic"
+                              configuration:
+                                model: "${secrets.s1.key-s}"
+                        """,
+                        """
+                        instance:
+                          streamingCluster:
+                            type: pulsar
+                          computeCluster:
+                            type: none
+                        """,
+                        """
+                        secrets:
+                          - id: s1
+                            data:
+                              key-s: value-s
+
+                        """,
+                        true,
+                        Map.of("dry-run", "true"))
+                .andExpect(
+                        content()
+                                .string(
+                                        """
+                                {
+                                  "resources" : { },
+                                  "modules" : [ {
+                                    "id" : "default",
+                                    "pipelines" : [ {
+                                      "id" : "app1",
+                                      "module" : "default",
+                                      "name" : "test",
+                                      "resources" : {
+                                        "parallelism" : 1,
+                                        "size" : 1
+                                      },
+                                      "errors" : {
+                                        "retries" : 0,
+                                        "on-failure" : "fail"
+                                      },
+                                      "agents" : [ {
+                                        "id" : "app1-ai-chat-completions-1",
+                                        "name" : "ai-chat-completions",
+                                        "type" : "ai-chat-completions",
+                                        "input" : null,
+                                        "output" : {
+                                          "connectionType" : "TOPIC",
+                                          "definition" : "history-topic",
+                                          "enableDeadletterQueue" : false
+                                        },
+                                        "configuration" : {
+                                          "model" : "value-s"
+                                        },
+                                        "resources" : {
+                                          "parallelism" : 1,
+                                          "size" : 1
+                                        },
+                                        "errors" : {
+                                          "retries" : 0,
+                                          "on-failure" : "fail"
+                                        }
+                                      } ]
+                                    } ],
+                                    "topics" : [ {
+                                      "name" : "history-topic",
+                                      "config" : null,
+                                      "options" : null,
+                                      "keySchema" : null,
+                                      "valueSchema" : null,
+                                      "partitions" : 0,
+                                      "implicit" : false,
+                                      "creation-mode" : "none",
+                                      "deletion-mode" : "none"
+                                    } ]
+                                  } ],
+                                  "instance" : {
+                                    "streamingCluster" : {
+                                      "type" : "pulsar",
+                                      "configuration" : { }
+                                    },
+                                    "computeCluster" : {
+                                      "type" : "none",
+                                      "configuration" : { }
+                                    },
+                                    "globals" : { }
+                                  }
+                                }"""));
+    }
 }