From 7979db46d005737038459fa65ee94ed72c21250b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 6 Oct 2023 09:22:28 +0200 Subject: [PATCH] Add dry-run apps deployment (#533) --- .../langstream/admin/client/AdminClient.java | 12 +- .../admin/client/model/Applications.java | 5 +- .../application/ApplicationDescription.java | 2 +- .../AbstractDeployApplicationCmd.java | 68 +++++++++-- .../docker/LocalRunApplicationCmd.java | 20 +++- .../commands/applications/AppsCmdTest.java | 62 +++++++++- .../src/main/assemble/entrypoint.sh | 2 +- .../src/main/docker/Dockerfile | 5 +- .../ai/langstream/runtime/tester/Main.java | 28 +++++ .../application/ApplicationResource.java | 37 ++++-- .../webservice/application/AppTestHelper.java | 34 +++++- .../application/ApplicationResourceTest.java | 107 ++++++++++++++++++ 12 files changed, 345 insertions(+), 37 deletions(-) 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 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-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 expectedAgents = new ArrayList<>(); List 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/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 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 secrets) + @RequestParam Optional 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 file, Optional instance, Optional 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 = 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 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" : { } + } + }""")); + } }