diff --git a/cli/flamingock-cli-executor/build.gradle.kts b/cli/flamingock-cli-executor/build.gradle.kts index 27fd6db06..5aa5c0513 100644 --- a/cli/flamingock-cli-executor/build.gradle.kts +++ b/cli/flamingock-cli-executor/build.gradle.kts @@ -5,11 +5,18 @@ plugins { description = "Flamingock CLI for executing changes in applications" +val jacksonVersion = "2.16.0" + dependencies { // CLI Framework implementation("info.picocli:picocli:4.7.5") annotationProcessor("info.picocli:picocli-codegen:4.7.5") + // Core dependencies for response handling + implementation(project(":core:flamingock-core-commons")) + implementation(project(":utils:general-util")) + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + // Test dependencies testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2") testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.2") diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java index ef67946d8..aa93c2f5f 100644 --- a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java @@ -15,6 +15,7 @@ */ package io.flamingock.cli.executor; +import io.flamingock.cli.executor.command.AuditCommand; import io.flamingock.cli.executor.command.ExecuteCommand; import io.flamingock.cli.executor.handler.ExecutorExceptionHandler; import io.flamingock.cli.executor.util.VersionProvider; @@ -41,11 +42,12 @@ "", "@|bold Examples:|@", " flamingock execute apply --jar ./app.jar", + " flamingock audit list --jar ./app.jar", " flamingock --verbose execute apply --jar ./my-app.jar", "", "For detailed help on any command, use: flamingock --help" }, - subcommands = {ExecuteCommand.class}, + subcommands = {ExecuteCommand.class, AuditCommand.class}, mixinStandardHelpOptions = true, versionProvider = VersionProvider.class ) diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ApplyCommand.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ApplyCommand.java index f2a8b8db2..2f8eeeee9 100644 --- a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ApplyCommand.java +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ApplyCommand.java @@ -18,13 +18,19 @@ import io.flamingock.cli.executor.FlamingockExecutorCli; import io.flamingock.cli.executor.output.ConsoleFormatter; import io.flamingock.cli.executor.process.JvmLauncher; +import io.flamingock.cli.executor.result.ResponseResultReader; +import io.flamingock.cli.executor.result.ResponseResultReader.ResponseResult; import io.flamingock.cli.executor.util.VersionProvider; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import picocli.CommandLine.ParentCommand; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.concurrent.Callable; /** @@ -54,6 +60,11 @@ public class ApplyCommand implements Callable { */ public static final int EXIT_JAR_NOT_FOUND = 126; + /** + * Operation string for EXECUTE operation (matches FlamingockArguments parsing). + */ + private static final String OPERATION_EXECUTE = "EXECUTE"; + @ParentCommand private ExecuteCommand parent; @@ -87,20 +98,55 @@ public Integer call() { ConsoleFormatter.printVerbose("JAR file: " + jarFile.getAbsolutePath(), verbose); ConsoleFormatter.printInfo("Launching Flamingock execution..."); - // Launch the Spring Boot application with CLI mode enabled - JvmLauncher launcher = new JvmLauncher(verbose); - int exitCode = launcher.launch(jarFile.getAbsolutePath()); + Path outputFile = null; + try { + outputFile = Files.createTempFile("flamingock-response-", ".json"); + ConsoleFormatter.printVerbose("Response file: " + outputFile, verbose); + + JvmLauncher launcher = new JvmLauncher(verbose); + int exitCode = launcher.launch(jarFile.getAbsolutePath(), OPERATION_EXECUTE, outputFile.toString()); + + if (exitCode == 0 && Files.exists(outputFile)) { + ResponseResultReader reader = new ResponseResultReader(); + ResponseResult result = reader.readTyped(outputFile, ExecuteResponseData.class); + + if (result.isSuccess()) { + if (!quiet) { + ConsoleFormatter.printSuccess(); + if (result.getData() != null && result.getData().getMessage() != null) { + ConsoleFormatter.printInfo(result.getData().getMessage()); + } + ConsoleFormatter.printInfo("Duration: " + result.getDurationMs() + "ms"); + } + } else { + ConsoleFormatter.printError("Error: " + result.getErrorMessage()); + return 1; + } + } else if (exitCode != 0) { + if (Files.exists(outputFile)) { + ResponseResultReader reader = new ResponseResultReader(); + ResponseResult result = reader.readTyped(outputFile, ExecuteResponseData.class); + if (!result.isSuccess()) { + ConsoleFormatter.printError("Error [" + result.getErrorCode() + "]: " + result.getErrorMessage()); + } + } + ConsoleFormatter.printFailure(); + return exitCode; + } - // Print result message - if (exitCode == 0) { - if (!quiet) { - ConsoleFormatter.printSuccess(); + return 0; + + } catch (IOException e) { + ConsoleFormatter.printError("Failed to create temporary file: " + e.getMessage()); + return 1; + } finally { + if (outputFile != null) { + try { + Files.deleteIfExists(outputFile); + } catch (IOException ignored) { + } } - } else { - ConsoleFormatter.printFailure(); } - - return exitCode; } private FlamingockExecutorCli getRootCommand() { diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/AuditCommand.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/AuditCommand.java new file mode 100644 index 000000000..0fea03510 --- /dev/null +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/AuditCommand.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.cli.executor.command; + +import picocli.CommandLine; +import picocli.CommandLine.Command; + +/** + * Parent command for audit operations. + * + *

Groups subcommands related to audit inspection:

+ *
    + *
  • {@code list} - List audit entries
  • + *
+ */ +@Command( + name = "audit", + description = "Audit operations for inspecting change history", + subcommands = {ListCommand.class}, + mixinStandardHelpOptions = true +) +public class AuditCommand implements Runnable { + + @CommandLine.ParentCommand + private Object parent; + + @Override + public void run() { + // Show help when no subcommand is specified + new CommandLine(this).usage(System.out); + } +} diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ListCommand.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ListCommand.java new file mode 100644 index 000000000..aa6251bf4 --- /dev/null +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/command/ListCommand.java @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.cli.executor.command; + +import io.flamingock.cli.executor.FlamingockExecutorCli; +import io.flamingock.cli.executor.output.ConsoleFormatter; +import io.flamingock.cli.executor.process.JvmLauncher; +import io.flamingock.cli.executor.result.ResponseResultReader; +import io.flamingock.cli.executor.result.ResponseResultReader.ResponseResult; +import io.flamingock.cli.executor.util.VersionProvider; +import io.flamingock.internal.common.core.response.data.AuditListResponseData; +import io.flamingock.internal.common.core.response.data.AuditListResponseData.AuditEntryDto; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Command to list audit entries from the Flamingock audit store. + * + *

This command spawns the user's application JAR with special flags + * that enable CLI mode in Flamingock and executes the LIST operation, + * which retrieves the audit history.

+ * + *

Exit codes:

+ *
    + *
  • 0 - Success
  • + *
  • 1 - Failure (execution error)
  • + *
  • 2 - Usage error (invalid CLI arguments)
  • + *
  • 126 - JAR not found
  • + *
+ */ +@Command( + name = "list", + description = "List audit entries from the change history", + mixinStandardHelpOptions = true +) +public class ListCommand implements Callable { + + /** + * Exit code when JAR file is not found. + */ + public static final int EXIT_JAR_NOT_FOUND = 126; + + /** + * Operation string for LIST operation (matches FlamingockArguments parsing). + */ + private static final String OPERATION_LIST = "LIST"; + + @ParentCommand + private AuditCommand parent; + + @Option(names = {"--jar", "-j"}, + description = "Path to the application JAR", + required = true) + private File jarFile; + + @Override + public Integer call() { + FlamingockExecutorCli root = getRootCommand(); + boolean verbose = root != null && root.isVerbose(); + boolean quiet = root != null && root.isQuiet(); + + // Print header unless quiet mode + if (!quiet) { + ConsoleFormatter.printHeader(VersionProvider.getVersionString()); + } + + // Validate JAR exists + if (!jarFile.exists()) { + ConsoleFormatter.printError("JAR file not found: " + jarFile.getAbsolutePath()); + return EXIT_JAR_NOT_FOUND; + } + + if (!jarFile.isFile()) { + ConsoleFormatter.printError("Path is not a file: " + jarFile.getAbsolutePath()); + return EXIT_JAR_NOT_FOUND; + } + + ConsoleFormatter.printVerbose("JAR file: " + jarFile.getAbsolutePath(), verbose); + ConsoleFormatter.printInfo("Launching Flamingock audit list..."); + + Path outputFile = null; + try { + outputFile = Files.createTempFile("flamingock-response-", ".json"); + ConsoleFormatter.printVerbose("Response file: " + outputFile, verbose); + + JvmLauncher launcher = new JvmLauncher(verbose); + int exitCode = launcher.launch(jarFile.getAbsolutePath(), OPERATION_LIST, outputFile.toString()); + + if (exitCode == 0 && Files.exists(outputFile)) { + ResponseResultReader reader = new ResponseResultReader(); + ResponseResult result = reader.readTyped(outputFile, AuditListResponseData.class); + + if (result.isSuccess() && result.getData() != null) { + displayAuditEntries(result.getData().getEntries(), quiet); + } else if (!result.isSuccess()) { + ConsoleFormatter.printError("Error: " + result.getErrorMessage()); + return 1; + } + } else if (exitCode != 0) { + if (Files.exists(outputFile)) { + ResponseResultReader reader = new ResponseResultReader(); + ResponseResult result = reader.readTyped(outputFile, AuditListResponseData.class); + if (!result.isSuccess()) { + ConsoleFormatter.printError("Error [" + result.getErrorCode() + "]: " + result.getErrorMessage()); + } + } + ConsoleFormatter.printError("Audit list operation failed."); + return exitCode; + } + + if (!quiet) { + ConsoleFormatter.printInfo("Audit list completed successfully."); + } + return 0; + + } catch (IOException e) { + ConsoleFormatter.printError("Failed to create temporary file: " + e.getMessage()); + return 1; + } finally { + if (outputFile != null) { + try { + Files.deleteIfExists(outputFile); + } catch (IOException ignored) { + } + } + } + } + + private void displayAuditEntries(List entries, boolean quiet) { + if (entries == null || entries.isEmpty()) { + if (!quiet) { + ConsoleFormatter.printInfo("No audit entries found."); + } + return; + } + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + System.out.println(); + System.out.printf("%-30s %-15s %-12s %-15s %-20s %10s%n", + "TASK ID", "AUTHOR", "STATE", "STAGE", "CREATED AT", "DURATION"); + System.out.println(repeatChar('-', 110)); + + for (AuditEntryDto entry : entries) { + String taskId = truncate(entry.getTaskId(), 30); + String author = truncate(entry.getAuthor(), 15); + String state = truncate(entry.getState(), 12); + String stageId = truncate(entry.getStageId(), 15); + String createdAt = entry.getCreatedAt() != null ? entry.getCreatedAt().format(formatter) : ""; + String duration = entry.getExecutionMillis() + "ms"; + + System.out.printf("%-30s %-15s %-12s %-15s %-20s %10s%n", + taskId, author, state, stageId, createdAt, duration); + } + + System.out.println(); + System.out.println("Total entries: " + entries.size()); + } + + private String truncate(String value, int maxLength) { + if (value == null) { + return ""; + } + if (value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength - 3) + "..."; + } + + private String repeatChar(char c, int count) { + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(c); + } + return sb.toString(); + } + + private FlamingockExecutorCli getRootCommand() { + if (parent == null) { + return null; + } + // Navigate up the command hierarchy to find the root + try { + java.lang.reflect.Field parentField = AuditCommand.class.getDeclaredField("parent"); + parentField.setAccessible(true); + Object grandParent = parentField.get(parent); + if (grandParent instanceof FlamingockExecutorCli) { + return (FlamingockExecutorCli) grandParent; + } + } catch (Exception e) { + // Fall through + } + return null; + } +} diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/process/JvmLauncher.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/process/JvmLauncher.java index 82242fffc..16cdfe4d1 100644 --- a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/process/JvmLauncher.java +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/process/JvmLauncher.java @@ -51,12 +51,37 @@ public JvmLauncher(boolean verbose) { /** * Launches the application with Flamingock CLI mode enabled. + * Uses the default EXECUTE operation. * * @param jarPath absolute path to the application JAR * @return the process exit code (0 = success, non-zero = failure) */ public int launch(String jarPath) { - List command = buildCommand(jarPath); + return launch(jarPath, null); + } + + /** + * Launches the application with Flamingock CLI mode enabled and a specific operation. + * + * @param jarPath absolute path to the application JAR + * @param operation the Flamingock operation to execute (e.g., "EXECUTE", "LIST"), or null for default + * @return the process exit code (0 = success, non-zero = failure) + */ + public int launch(String jarPath, String operation) { + return launch(jarPath, operation, null); + } + + /** + * Launches the application with Flamingock CLI mode enabled, a specific operation, + * and an optional output file for result communication. + * + * @param jarPath absolute path to the application JAR + * @param operation the Flamingock operation to execute (e.g., "EXECUTE", "LIST"), or null for default + * @param outputFile path to the output file for result communication, or null if not needed + * @return the process exit code (0 = success, non-zero = failure) + */ + public int launch(String jarPath, String operation, String outputFile) { + List command = buildCommand(jarPath, operation, outputFile); if (verbose) { ConsoleFormatter.printVerbose("Executing command: " + String.join(" ", command), true); @@ -98,10 +123,23 @@ public int launch(String jarPath) { /** * Builds the command line for launching the application. * - * @param jarPath path to the JAR file + * @param jarPath path to the JAR file + * @param operation the Flamingock operation to execute, or null for default * @return the command as a list of strings */ - List buildCommand(String jarPath) { + List buildCommand(String jarPath, String operation) { + return buildCommand(jarPath, operation, null); + } + + /** + * Builds the command line for launching the application. + * + * @param jarPath path to the JAR file + * @param operation the Flamingock operation to execute, or null for default + * @param outputFile path to the output file for result communication, or null if not needed + * @return the command as a list of strings + */ + List buildCommand(String jarPath, String operation, String outputFile) { List command = new ArrayList<>(); // Find the java executable @@ -123,6 +161,16 @@ List buildCommand(String jarPath) { // Enable Flamingock CLI mode command.add("--flamingock.cli.mode=true"); + // Add operation if specified + if (operation != null && !operation.isEmpty()) { + command.add("--flamingock.operation=" + operation); + } + + // Add output file if specified + if (outputFile != null && !outputFile.isEmpty()) { + command.add("--flamingock.output-file=" + outputFile); + } + return command; } diff --git a/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/result/ResponseResultReader.java b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/result/ResponseResultReader.java new file mode 100644 index 000000000..01a5d01b2 --- /dev/null +++ b/cli/flamingock-cli-executor/src/main/java/io/flamingock/cli/executor/result/ResponseResultReader.java @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.cli.executor.result; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.flamingock.internal.common.core.response.ResponseEnvelope; +import io.flamingock.internal.common.core.response.data.AuditListResponseData; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; +import io.flamingock.internal.util.JsonObjectMapper; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Reads and parses response files from spawned application processes. + */ +public class ResponseResultReader { + + private final ObjectMapper objectMapper; + + public ResponseResultReader() { + this.objectMapper = JsonObjectMapper.DEFAULT_INSTANCE.copy(); + this.objectMapper.registerModule(new JavaTimeModule()); + registerSubtypes(); + } + + private void registerSubtypes() { + objectMapper.registerSubtypes( + new NamedType(AuditListResponseData.class, "audit_list"), + new NamedType(ExecuteResponseData.class, "execute") + ); + } + + /** + * Reads a response envelope from a file. + * + * @param filePath the path to the response file + * @return the parsed response envelope, or empty if the file doesn't exist or parsing fails + */ + public Optional read(Path filePath) { + if (!Files.exists(filePath)) { + return Optional.empty(); + } + + try { + ResponseEnvelope envelope = objectMapper.readValue(filePath.toFile(), ResponseEnvelope.class); + return Optional.of(envelope); + } catch (IOException e) { + return Optional.empty(); + } + } + + /** + * Reads a response envelope from a file and returns it as a typed result. + * + * @param filePath the path to the response file + * @param dataType the expected type of the data field + * @param the data type + * @return the typed response result + */ + public ResponseResult readTyped(Path filePath, Class dataType) { + Optional envelope = read(filePath); + + if (!envelope.isPresent()) { + return ResponseResult.readError("Response file not found or could not be read: " + filePath); + } + + ResponseEnvelope env = envelope.get(); + + if (!env.isSuccess()) { + return ResponseResult.fromFailure(env); + } + + Object data = env.getData(); + if (data == null) { + return ResponseResult.success(env, null); + } + + if (dataType.isInstance(data)) { + return ResponseResult.success(env, dataType.cast(data)); + } + + try { + T typedData = objectMapper.convertValue(data, dataType); + return ResponseResult.success(env, typedData); + } catch (Exception e) { + return ResponseResult.readError("Failed to convert response data to " + dataType.getSimpleName()); + } + } + + /** + * Represents a typed response result. + * + * @param the data type + */ + public static class ResponseResult { + private final boolean success; + private final T data; + private final String errorCode; + private final String errorMessage; + private final boolean recoverable; + private final long durationMs; + + private ResponseResult(boolean success, T data, String errorCode, String errorMessage, boolean recoverable, long durationMs) { + this.success = success; + this.data = data; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.recoverable = recoverable; + this.durationMs = durationMs; + } + + public static ResponseResult success(ResponseEnvelope envelope, T data) { + return new ResponseResult<>(true, data, null, null, false, envelope.getDurationMs()); + } + + public static ResponseResult fromFailure(ResponseEnvelope envelope) { + String code = envelope.getError() != null ? envelope.getError().getCode() : "UNKNOWN_ERROR"; + String message = envelope.getError() != null ? envelope.getError().getMessage() : "Unknown error"; + boolean recoverable = envelope.getError() != null && envelope.getError().isRecoverable(); + return new ResponseResult<>(false, null, code, message, recoverable, envelope.getDurationMs()); + } + + public static ResponseResult readError(String message) { + return new ResponseResult<>(false, null, "READ_ERROR", message, false, 0); + } + + public boolean isSuccess() { + return success; + } + + public T getData() { + return data; + } + + public String getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public boolean isRecoverable() { + return recoverable; + } + + public long getDurationMs() { + return durationMs; + } + } +} diff --git a/cli/flamingock-cli-executor/src/test/java/io/flamingock/cli/executor/process/JvmLauncherTest.java b/cli/flamingock-cli-executor/src/test/java/io/flamingock/cli/executor/process/JvmLauncherTest.java index cb9331ab4..5fb55a5d6 100644 --- a/cli/flamingock-cli-executor/src/test/java/io/flamingock/cli/executor/process/JvmLauncherTest.java +++ b/cli/flamingock-cli-executor/src/test/java/io/flamingock/cli/executor/process/JvmLauncherTest.java @@ -30,7 +30,7 @@ class JvmLauncherTest { @Test void buildCommand_shouldContainJarFlag() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); assertTrue(command.contains("-jar")); assertTrue(command.contains("/path/to/app.jar")); @@ -39,7 +39,7 @@ void buildCommand_shouldContainJarFlag() { @Test void buildCommand_shouldContainSpringWebDisabled() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); assertTrue(command.contains("--spring.main.web-application-type=none")); } @@ -47,7 +47,7 @@ void buildCommand_shouldContainSpringWebDisabled() { @Test void buildCommand_shouldContainCliProfile() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); assertTrue(command.contains("--spring.profiles.include=flamingock-cli")); } @@ -55,7 +55,7 @@ void buildCommand_shouldContainCliProfile() { @Test void buildCommand_shouldContainCliModeFlag() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); assertTrue(command.contains("--flamingock.cli.mode=true")); } @@ -63,20 +63,50 @@ void buildCommand_shouldContainCliModeFlag() { @Test void buildCommand_shouldDisableBanner() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); assertTrue(command.contains("--spring.main.banner-mode=off")); } @Test - void buildCommand_shouldHaveCorrectFlagCount() { + void buildCommand_shouldHaveCorrectFlagCountWithoutOperation() { JvmLauncher launcher = new JvmLauncher(false); - List command = launcher.buildCommand("/path/to/app.jar"); + List command = launcher.buildCommand("/path/to/app.jar", null); // java -jar + 4 flags = 7 elements assertEquals(7, command.size()); } + @Test + void buildCommand_shouldIncludeOperationWhenProvided() { + JvmLauncher launcher = new JvmLauncher(false); + List command = launcher.buildCommand("/path/to/app.jar", "EXECUTE"); + + assertTrue(command.contains("--flamingock.operation=EXECUTE")); + // java -jar + 4 flags + operation = 8 elements + assertEquals(8, command.size()); + } + + @Test + void buildCommand_shouldIncludeListOperation() { + JvmLauncher launcher = new JvmLauncher(false); + List command = launcher.buildCommand("/path/to/app.jar", "LIST"); + + assertTrue(command.contains("--flamingock.operation=LIST")); + } + + @Test + void buildCommand_shouldNotIncludeOperationWhenEmpty() { + JvmLauncher launcher = new JvmLauncher(false); + List command = launcher.buildCommand("/path/to/app.jar", ""); + + // Should not contain any operation flag + for (String arg : command) { + assertTrue(!arg.startsWith("--flamingock.operation=")); + } + assertEquals(7, command.size()); + } + @Test void getJavaExecutable_shouldReturnNonEmpty() { JvmLauncher launcher = new JvmLauncher(false); diff --git a/core/flamingock-core-commons/build.gradle.kts b/core/flamingock-core-commons/build.gradle.kts index b5853edfc..c0b04e495 100644 --- a/core/flamingock-core-commons/build.gradle.kts +++ b/core/flamingock-core-commons/build.gradle.kts @@ -2,7 +2,6 @@ val jacksonVersion = "2.16.0" dependencies { api(project(":utils:general-util")) api(project(":core:flamingock-core-api")) - implementation(project(":utils:general-util")) api("jakarta.annotation:jakarta.annotation-api:2.1.1")//todo can this be implementation? implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/FileResponseChannel.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/FileResponseChannel.java new file mode 100644 index 000000000..23b257e37 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/FileResponseChannel.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +/** + * Response channel implementation that writes to a file. + * Uses atomic write (temp file + rename) to ensure consistency. + */ +public class FileResponseChannel implements ResponseChannel { + + private final Path outputPath; + private final ObjectMapper objectMapper; + + public FileResponseChannel(String outputFilePath, ObjectMapper objectMapper) { + this.outputPath = Paths.get(outputFilePath); + this.objectMapper = objectMapper; + } + + @Override + public void write(ResponseEnvelope envelope) throws ResponseChannelException { + Path tempFile = null; + try { + Path parentDir = outputPath.getParent(); + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir); + } + + tempFile = Files.createTempFile( + parentDir != null ? parentDir : Paths.get(System.getProperty("java.io.tmpdir")), + "flamingock-response-", + ".tmp" + ); + + objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempFile.toFile(), envelope); + + Files.move(tempFile, outputPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + + } catch (IOException e) { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignored) { + } + } + throw new ResponseChannelException("Failed to write response to file: " + outputPath, e); + } + } + + @Override + public void close() { + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/NoOpResponseChannel.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/NoOpResponseChannel.java new file mode 100644 index 000000000..7801bc052 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/NoOpResponseChannel.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +/** + * A response channel that does nothing. + * Used in CLI mode when no output file is specified. + */ +public class NoOpResponseChannel implements ResponseChannel { + + @Override + public void write(ResponseEnvelope envelope) throws ResponseChannelException { + // No-op: discard the response + } + + @Override + public void close() { + // No-op: nothing to close + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannel.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannel.java new file mode 100644 index 000000000..d2cbc825d --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannel.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +/** + * Interface for writing operation results to a communication channel. + * This abstraction allows different implementations for file-based, + * stdout-based, or network-based result communication. + */ +public interface ResponseChannel { + + /** + * Writes the response envelope to the channel. + * + * @param envelope the response envelope to write + * @throws ResponseChannelException if writing fails + */ + void write(ResponseEnvelope envelope) throws ResponseChannelException; + + /** + * Closes the channel and releases any resources. + */ + void close(); +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannelException.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannelException.java new file mode 100644 index 000000000..68f01e417 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseChannelException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +/** + * Exception thrown when a response channel operation fails. + */ +public class ResponseChannelException extends RuntimeException { + + public ResponseChannelException(String message) { + super(message); + } + + public ResponseChannelException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseEnvelope.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseEnvelope.java new file mode 100644 index 000000000..961d51997 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseEnvelope.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.time.Instant; + +/** + * Envelope for operation results communicated between the spawned application + * and the CLI executor. + */ +public class ResponseEnvelope { + + private boolean success; + private String operation; + private Instant timestamp; + private long durationMs; + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type") + private Object data; + + private ResponseError error; + + public ResponseEnvelope() { + } + + private ResponseEnvelope(boolean success, String operation, Instant timestamp, long durationMs, Object data, ResponseError error) { + this.success = success; + this.operation = operation; + this.timestamp = timestamp; + this.durationMs = durationMs; + this.data = data; + this.error = error; + } + + public static ResponseEnvelope success(String operation, Object data, long durationMs) { + return new ResponseEnvelope(true, operation, Instant.now(), durationMs, data, null); + } + + public static ResponseEnvelope failure(String operation, ResponseError error, long durationMs) { + return new ResponseEnvelope(false, operation, Instant.now(), durationMs, null, error); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getOperation() { + return operation; + } + + public void setOperation(String operation) { + this.operation = operation; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + public long getDurationMs() { + return durationMs; + } + + public void setDurationMs(long durationMs) { + this.durationMs = durationMs; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } + + public ResponseError getError() { + return error; + } + + public void setError(ResponseError error) { + this.error = error; + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseError.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseError.java new file mode 100644 index 000000000..32e0091ac --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/ResponseError.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response; + +/** + * Represents an error that occurred during operation execution. + */ +public class ResponseError { + + private String code; + private String message; + private boolean recoverable; + + public ResponseError() { + } + + public ResponseError(String code, String message, boolean recoverable) { + this.code = code; + this.message = message; + this.recoverable = recoverable; + } + + public static ResponseError from(Throwable throwable) { + String code = deriveErrorCode(throwable); + String message = throwable.getMessage() != null ? throwable.getMessage() : throwable.getClass().getSimpleName(); + boolean recoverable = isRecoverable(throwable); + return new ResponseError(code, message, recoverable); + } + + private static String deriveErrorCode(Throwable throwable) { + String className = throwable.getClass().getSimpleName(); + if (className.contains("Lock")) { + return "LOCK_ERROR"; + } else if (className.contains("Audit") || className.contains("Store")) { + return "AUDIT_STORE_ERROR"; + } else if (className.contains("Connection") || className.contains("Database")) { + return "CONNECTION_ERROR"; + } else if (className.contains("Bootstrap") || className.contains("Config")) { + return "BOOTSTRAP_FAILURE"; + } + return "EXECUTION_ERROR"; + } + + private static boolean isRecoverable(Throwable throwable) { + String className = throwable.getClass().getSimpleName(); + return className.contains("Lock") || className.contains("Connection") || className.contains("Timeout"); + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public boolean isRecoverable() { + return recoverable; + } + + public void setRecoverable(boolean recoverable) { + this.recoverable = recoverable; + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/AuditListResponseData.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/AuditListResponseData.java new file mode 100644 index 000000000..9c9c2fc68 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/AuditListResponseData.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response.data; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Response data for the LIST operation containing audit entries. + */ +@JsonTypeName("audit_list") +public class AuditListResponseData { + + private List entries; + + public AuditListResponseData() { + this.entries = new ArrayList<>(); + } + + public AuditListResponseData(List entries) { + this.entries = entries != null ? entries : new ArrayList<>(); + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } + + /** + * DTO representing a single audit entry for CLI response. + */ + public static class AuditEntryDto { + private String taskId; + private String author; + private String state; + private String stageId; + private LocalDateTime createdAt; + private long executionMillis; + + public AuditEntryDto() { + } + + public AuditEntryDto(String taskId, String author, String state, String stageId, LocalDateTime createdAt, long executionMillis) { + this.taskId = taskId; + this.author = author; + this.state = state; + this.stageId = stageId; + this.createdAt = createdAt; + this.executionMillis = executionMillis; + } + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getStageId() { + return stageId; + } + + public void setStageId(String stageId) { + this.stageId = stageId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public long getExecutionMillis() { + return executionMillis; + } + + public void setExecutionMillis(long executionMillis) { + this.executionMillis = executionMillis; + } + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecuteResponseData.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecuteResponseData.java new file mode 100644 index 000000000..689f3bc1f --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecuteResponseData.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.common.core.response.data; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +/** + * Response data for the EXECUTE operation. + * Currently a placeholder - detailed results will be available in a future version. + */ +@JsonTypeName("execute") +public class ExecuteResponseData { + + private String message; + + public ExecuteResponseData() { + } + + public ExecuteResponseData(String message) { + this.message = message; + } + + public static ExecuteResponseData placeholder() { + return new ExecuteResponseData("Execution completed. Detailed results will be available in a future version."); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java index 517a6db09..a88c34940 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java @@ -46,9 +46,9 @@ import io.flamingock.internal.core.plugin.Plugin; import io.flamingock.internal.core.plugin.PluginManager; import io.flamingock.internal.core.builder.args.FlamingockArguments; -import io.flamingock.internal.core.builder.runner.DefaultRunner; import io.flamingock.internal.core.builder.runner.Runner; import io.flamingock.internal.core.builder.runner.RunnerBuilder; +import io.flamingock.internal.core.builder.runner.RunnerFactory; import io.flamingock.internal.core.task.filter.TaskFilter; import io.flamingock.internal.util.CollectionUtil; import io.flamingock.internal.util.Property; @@ -205,9 +205,11 @@ public final Runner build() { LoadedPipeline pipeline = loadPipeline(); pipeline.contributeToContext(hierarchicalContext); + FlamingockArguments flamingockArgs = FlamingockArguments.parse(applicationArgs); + OperationFactory operationFactory = new OperationFactory( runnerId, - FlamingockArguments.parse(applicationArgs), + flamingockArgs, pipeline, persistence, buildExecutionPlanner(runnerId), @@ -220,7 +222,8 @@ public final Runner build() { persistence.getCloser() ); RunnableOperation operation = operationFactory.getOperation(); - return new DefaultRunner(runnerId, operation, persistence.getCloser()); + + return new RunnerFactory(runnerId, flamingockArgs, operation, persistence.getCloser()).create(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/CliRunner.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/CliRunner.java new file mode 100644 index 000000000..237524c7a --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/CliRunner.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.core.builder.runner; + +import io.flamingock.internal.common.core.response.ResponseChannel; +import io.flamingock.internal.common.core.response.ResponseEnvelope; +import io.flamingock.internal.common.core.response.ResponseError; +import io.flamingock.internal.core.operation.AbstractOperationResult; +import io.flamingock.internal.core.operation.OperationType; +import io.flamingock.internal.core.operation.RunnableOperation; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import org.slf4j.Logger; + +/** + * CLI-specific runner that writes operation results to a response channel, + * flushes output streams, and exits with the appropriate code. + */ +public class CliRunner implements Runner { + + private static final Logger logger = FlamingockLoggerFactory.getLogger("flamingock.cli.runner"); + + private final RunnableOperation operation; + private final Runnable finalizer; + private final ResponseChannel channel; + private final OperationType operationType; + + public CliRunner(RunnableOperation operation, + Runnable finalizer, + ResponseChannel channel, + OperationType operationType) { + this.operation = operation; + this.finalizer = finalizer; + this.channel = channel; + this.operationType = operationType; + } + + @Override + public void run() { + long startTime = System.currentTimeMillis(); + AbstractOperationResult result = null; + Throwable error = null; + int exitCode = 0; + + try { + result = operation.run(); + } catch (Throwable t) { + error = t; + exitCode = 1; + logger.error("Operation failed", t); + } finally { + long durationMs = System.currentTimeMillis() - startTime; + + try { + writeResponse(result, error, durationMs); + } catch (Exception e) { + logger.error("Failed to write response", e); + if (exitCode == 0) { + exitCode = 1; + } + } + + try { + finalizer.run(); + } catch (Exception e) { + logger.error("Finalizer failed", e); + } + + try { + channel.close(); + } catch (Exception e) { + logger.error("Failed to close channel", e); + } + + System.out.flush(); + System.err.flush(); + System.exit(exitCode); + } + } + + private void writeResponse(AbstractOperationResult result, Throwable error, long durationMs) { + ResponseEnvelope envelope; + String operationName = operationType.name(); + + if (error != null) { + envelope = ResponseEnvelope.failure(operationName, ResponseError.from(error), durationMs); + } else { + Object data = result != null ? result.toResponseData() : null; + envelope = ResponseEnvelope.success(operationName, data, durationMs); + } + + channel.write(envelope); + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/DefaultRunner.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/DefaultRunner.java index c3b739a91..2d437a50e 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/DefaultRunner.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/DefaultRunner.java @@ -41,7 +41,7 @@ public DefaultRunner(RunnerId runnerId, public void run() { try { AbstractOperationResult result = operation.run(); - //todo process result + //todo process result. Maybe just printing result } finally { finalizer.run(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/RunnerFactory.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/RunnerFactory.java new file mode 100644 index 000000000..2b085605a --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/runner/RunnerFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.internal.core.builder.runner; + +import io.flamingock.internal.common.core.response.FileResponseChannel; +import io.flamingock.internal.common.core.response.NoOpResponseChannel; +import io.flamingock.internal.common.core.response.ResponseChannel; +import io.flamingock.internal.core.builder.args.FlamingockArguments; +import io.flamingock.internal.core.operation.RunnableOperation; +import io.flamingock.internal.util.JsonObjectMapper; +import io.flamingock.internal.util.id.RunnerId; + +/** + * Factory for creating the appropriate Runner based on the execution mode. + */ +public class RunnerFactory { + + private final RunnerId runnerId; + private final FlamingockArguments flamingockArgs; + private final RunnableOperation operation; + private final Runnable finalizer; + + public RunnerFactory(RunnerId runnerId, + FlamingockArguments flamingockArgs, + RunnableOperation operation, + Runnable finalizer) { + this.runnerId = runnerId; + this.flamingockArgs = flamingockArgs; + this.operation = operation; + this.finalizer = finalizer; + } + + /** + * Creates the appropriate runner based on the execution mode. + * + * @return CliRunner when in CLI mode, DefaultRunner otherwise + */ + public Runner create() { + if (flamingockArgs.isCliMode()) { + return createCliRunner(); + } + return createDefaultRunner(); + } + + private Runner createCliRunner() { + ResponseChannel channel = flamingockArgs.getOutputFile() + .map(outputFile -> (ResponseChannel) new FileResponseChannel(outputFile, JsonObjectMapper.DEFAULT_INSTANCE)) + .orElseGet(NoOpResponseChannel::new); + + return new CliRunner(operation, finalizer, channel, flamingockArgs.getOperation()); + } + + private Runner createDefaultRunner() { + return new DefaultRunner(runnerId, operation, finalizer); + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractOperationResult.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractOperationResult.java index f1f939ed7..fcb2c20d9 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractOperationResult.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractOperationResult.java @@ -15,5 +15,15 @@ */ package io.flamingock.internal.core.operation; +/** + * Base class for all operation results. + */ public abstract class AbstractOperationResult { + + /** + * Transforms this result into a response data object suitable for CLI communication. + * + * @return the response data object for this result + */ + public abstract Object toResponseData(); } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AuditListResult.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AuditListResult.java index 00dd0ff17..98a3c97b3 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AuditListResult.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AuditListResult.java @@ -16,8 +16,11 @@ package io.flamingock.internal.core.operation; import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.response.data.AuditListResponseData; +import io.flamingock.internal.common.core.response.data.AuditListResponseData.AuditEntryDto; import java.util.List; +import java.util.stream.Collectors; public class AuditListResult extends AbstractOperationResult { private final List auditEntries; @@ -29,4 +32,19 @@ public AuditListResult(List auditEntries) { public List getAuditEntries() { return auditEntries; } + + @Override + public Object toResponseData() { + List dtos = auditEntries.stream() + .map(entry -> new AuditEntryDto( + entry.getTaskId(), + entry.getAuthor(), + entry.getState() != null ? entry.getState().name() : null, + entry.getStageId(), + entry.getCreatedAt(), + entry.getExecutionMillis() + )) + .collect(Collectors.toList()); + return new AuditListResponseData(dtos); + } } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/ExecuteResult.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/ExecuteResult.java index bdb9dd205..cac2f9884 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/ExecuteResult.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/ExecuteResult.java @@ -15,5 +15,12 @@ */ package io.flamingock.internal.core.operation; +import io.flamingock.internal.common.core.response.data.ExecuteResponseData; + public class ExecuteResult extends AbstractOperationResult { + + @Override + public Object toResponseData() { + return ExecuteResponseData.placeholder(); + } } diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootUtil.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootUtil.java index fd0f39fa6..0ce85fdbc 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootUtil.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootUtil.java @@ -15,6 +15,7 @@ */ package io.flamingock.springboot; +import io.flamingock.internal.core.builder.AbstractChangeRunnerBuilder; import io.flamingock.internal.core.builder.runner.Runner; import io.flamingock.internal.core.builder.runner.RunnerBuilder; import org.springframework.beans.factory.InitializingBean; @@ -41,27 +42,19 @@ public static ApplicationRunner toApplicationRunner(RunnerBuilder runnerBuilder) } /** - * Creates an ApplicationRunner for CLI mode that executes Flamingock and then - * calls System.exit() with the appropriate exit code. + * Creates an ApplicationRunner for CLI mode that executes Flamingock. + * If a CliRunner is built (when output file is specified), it handles + * flush and exit internally. Otherwise, this method handles them. * * @param runnerBuilder the runner builder - * @return an ApplicationRunner that exits after execution + * @return an ApplicationRunner for CLI execution */ - public static ApplicationRunner toCliApplicationRunner(RunnerBuilder runnerBuilder) { + public static ApplicationRunner toCliApplicationRunner(AbstractChangeRunnerBuilder runnerBuilder) { return args -> { - try { - Runner runner = runnerBuilder.build(); - runner.run(); - // Flush output before exit - System.out.flush(); - System.err.flush(); - System.exit(0); - } catch (Exception e) { - // Flush output before exit - System.out.flush(); - System.err.flush(); - System.exit(1); - } + Runner runner = runnerBuilder + .setApplicationArguments(args.getSourceArgs()) + .build(); + runner.run(); }; }