diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/error/PendingChangesException.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/error/PendingChangesException.java new file mode 100644 index 000000000..27e3a7d6a --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/error/PendingChangesException.java @@ -0,0 +1,26 @@ +/* + * 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.error; + +/** + * Exception thrown when Flamingock runs in validation-only mode and detects pending changes. + */ +public class PendingChangesException extends FlamingockException { + + public PendingChangesException() { + super("Flamingock validationOnly=true: pending changes detected. Apply them before running in validation-only mode."); + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/operation/OperationType.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/operation/OperationType.java index 5a5f992c8..b57a3a800 100644 --- a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/operation/OperationType.java +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/operation/OperationType.java @@ -18,8 +18,8 @@ public enum OperationType { EXECUTE_APPLY, EXECUTE_ROLLBACK, - EXECUTE_VALIDATE, EXECUTE_DRYRUN, + VALIDATE, AUDIT_LIST, AUDIT_FIX, ISSUE_LIST, 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 0ebb3eebf..3e6b619bf 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 @@ -31,6 +31,7 @@ import io.flamingock.internal.core.context.SimpleContext; import io.flamingock.internal.core.external.store.AuditStore; import io.flamingock.internal.core.external.store.audit.AuditPersistence; +import io.flamingock.internal.core.operation.OperationResolver; import io.flamingock.internal.core.plan.ExecutionPlanner; import io.flamingock.internal.core.event.CompositeEventPublisher; import io.flamingock.internal.core.event.EventPublisher; @@ -43,7 +44,6 @@ import io.flamingock.internal.core.event.model.IStageFailedEvent; import io.flamingock.internal.core.event.model.IStageIgnoredEvent; import io.flamingock.internal.core.event.model.IStageStartedEvent; -import io.flamingock.internal.core.operation.OperationFactory; import io.flamingock.internal.core.operation.RunnableOperation; import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; import io.flamingock.internal.core.plugin.Plugin; @@ -213,7 +213,7 @@ public final Runner build() { FlamingockArguments flamingockArgs = FlamingockArguments.parse(applicationArgs); - OperationFactory operationFactory = new OperationFactory( + OperationResolver operationResolver = new OperationResolver( runnerId, flamingockArgs, pipeline, @@ -227,7 +227,7 @@ public final Runner build() { coreConfiguration.isThrowExceptionIfCannotObtainLock(), persistence.getCloser() ); - RunnableOperation operation = operationFactory.getOperation(); + RunnableOperation operation = operationResolver.getOperation(); return new RunnerFactory(runnerId, flamingockArgs, operation, persistence.getCloser()).create(); } @@ -359,6 +359,15 @@ public HOLDER setEnabled(boolean enabled) { return getSelf(); } + public HOLDER setValidationOnly(boolean validationOnly) { + coreConfiguration.setValidationOnly(validationOnly); + return getSelf(); + } + + public boolean isValidationOnly() { + return coreConfiguration.isValidationOnly(); + } + @Override public HOLDER setServiceIdentifier(String serviceIdentifier) { coreConfiguration.setServiceIdentifier(serviceIdentifier); diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/args/FlamingockArguments.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/args/FlamingockArguments.java index eed899be5..d92328917 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/args/FlamingockArguments.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/args/FlamingockArguments.java @@ -35,25 +35,22 @@ public class FlamingockArguments { private final boolean cliMode; private final OperationType operation; - private final boolean operationProvided; private final String outputFile; private final Map remainingArgs; private FlamingockArguments(boolean cliMode, OperationType operation, - boolean operationProvided, String outputFile, Map remainingArgs) { this.cliMode = cliMode; this.operation = operation; - this.operationProvided = operationProvided; this.outputFile = outputFile; this.remainingArgs = Collections.unmodifiableMap(remainingArgs); } public static FlamingockArguments parse(String[] args) { if (args == null || args.length == 0) { - return new FlamingockArguments(false, OperationType.EXECUTE_APPLY, false, null, Collections.emptyMap()); + return new FlamingockArguments(false, null, null, Collections.emptyMap()); } boolean cliMode = false; @@ -105,8 +102,8 @@ public static FlamingockArguments parse(String[] args) { } } - OperationType effectiveOperation = operationProvided ? operation : OperationType.EXECUTE_APPLY; - return new FlamingockArguments(cliMode, effectiveOperation, operationProvided, outputFile, remaining); + OperationType effectiveOperation = operationProvided ? operation : null; + return new FlamingockArguments(cliMode, effectiveOperation, outputFile, remaining); } private static boolean parseBoolean(String key, String value) { @@ -150,12 +147,8 @@ public boolean isCliMode() { return cliMode; } - public OperationType getOperation() { - return operation; - } - - public boolean isOperationProvided() { - return operationProvided; + public Optional getOperation() { + return Optional.ofNullable(operation); } public Optional getOutputFile() { 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 index 50d8fd75f..0c694ce20 100644 --- 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 @@ -18,8 +18,8 @@ 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.builder.args.FlamingockArguments; import io.flamingock.internal.core.operation.AbstractOperationResult; -import io.flamingock.internal.common.core.operation.OperationType; import io.flamingock.internal.core.operation.RunnableOperation; import io.flamingock.internal.util.log.FlamingockLoggerFactory; import org.slf4j.Logger; @@ -35,16 +35,16 @@ public class CliRunner implements Runner { private final RunnableOperation operation; private final Runnable finalizer; private final ResponseChannel channel; - private final OperationType operationType; + private final FlamingockArguments flamingockArgs; public CliRunner(RunnableOperation operation, Runnable finalizer, ResponseChannel channel, - OperationType operationType) { + FlamingockArguments flamingockArgs) { this.operation = operation; this.finalizer = finalizer; this.channel = channel; - this.operationType = operationType; + this.flamingockArgs = flamingockArgs; } @Override @@ -92,7 +92,9 @@ public void run() { private void writeResponse(AbstractOperationResult result, Throwable error, long durationMs) { ResponseEnvelope envelope; - String operationName = operationType.name(); + String operationName = flamingockArgs.getOperation() + .map(Enum::name) + .orElse("unknown"); if (error != null) { envelope = ResponseEnvelope.failure(operationName, ResponseError.from(error), durationMs); 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 index 2b085605a..f049b2837 100644 --- 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 @@ -60,7 +60,7 @@ private Runner createCliRunner() { .map(outputFile -> (ResponseChannel) new FileResponseChannel(outputFile, JsonObjectMapper.DEFAULT_INSTANCE)) .orElseGet(NoOpResponseChannel::new); - return new CliRunner(operation, finalizer, channel, flamingockArgs.getOperation()); + return new CliRunner(operation, finalizer, channel, flamingockArgs); } private Runner createDefaultRunner() { diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java index 6cc0b23e3..7cdc18423 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java @@ -38,6 +38,10 @@ public interface CoreConfigurable { void setEnabled(boolean enabled); + void setValidationOnly(boolean validationOnly); + + boolean isValidationOnly(); + void setServiceIdentifier(String serviceIdentifier); void setMetadata(Map metadata); diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java index 46d90f86d..b2dc6715a 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java @@ -35,6 +35,13 @@ public class CoreConfiguration implements CoreConfigurable { */ private boolean enabled = true; + /** + * If true, Flamingock will only validate that no pending changes exist without applying them + * When Flamingock runs through the CLI, the CLI operation takes precedence over this flag + * Default false + */ + private boolean validationOnly = false; + /** * Service identifier. */ @@ -91,6 +98,11 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } + @Override + public void setValidationOnly(boolean validationOnly) { + this.validationOnly = validationOnly; + } + @Override public void setServiceIdentifier(String serviceIdentifier) { this.serviceIdentifier = serviceIdentifier; @@ -126,6 +138,11 @@ public boolean isEnabled() { return enabled; } + @Override + public boolean isValidationOnly() { + return validationOnly; + } + @Override public String getServiceIdentifier() { return serviceIdentifier; diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteOperation.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java similarity index 88% rename from core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteOperation.java rename to core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java index fa252d34a..2903a1a39 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteOperation.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.internal.core.operation.execute; +package io.flamingock.internal.core.operation; import io.flamingock.internal.common.core.error.FlamingockException; +import io.flamingock.internal.common.core.error.PendingChangesException; import io.flamingock.internal.common.core.response.data.ErrorInfo; import io.flamingock.internal.common.core.response.data.ExecuteResponseData; import io.flamingock.internal.common.core.response.data.StageResult; @@ -26,8 +27,8 @@ import io.flamingock.internal.core.event.model.impl.StageCompletedEvent; import io.flamingock.internal.core.event.model.impl.StageFailedEvent; import io.flamingock.internal.core.event.model.impl.StageStartedEvent; -import io.flamingock.internal.core.operation.Operation; -import io.flamingock.internal.core.operation.OperationException; +import io.flamingock.internal.core.operation.execute.ExecuteArgs; +import io.flamingock.internal.core.operation.execute.ExecuteResult; import io.flamingock.internal.core.operation.result.ExecutionResultBuilder; import io.flamingock.internal.core.pipeline.execution.ExecutableStage; import io.flamingock.internal.core.pipeline.execution.ExecutionContext; @@ -48,9 +49,9 @@ import java.util.List; /** - * Executes the pipeline and returns structured result data. + * Common execution flow for Apply and Validate operations */ -public class ExecuteOperation implements Operation { +public abstract class AbstractPipelineTraverseOperation implements Operation { private static final Logger logger = FlamingockLoggerFactory.getLogger("PipelineRunner"); @@ -66,15 +67,15 @@ public class ExecuteOperation implements Operation { private final OrphanExecutionContext orphanExecutionContext; - private final Runnable finalizer; + protected final Runnable finalizer; - public ExecuteOperation(RunnerId runnerId, - ExecutionPlanner executionPlanner, - StageExecutor stageExecutor, - OrphanExecutionContext orphanExecutionContext, - EventPublisher eventPublisher, - boolean throwExceptionIfCannotObtainLock, - Runnable finalizer) { + public AbstractPipelineTraverseOperation(RunnerId runnerId, + ExecutionPlanner executionPlanner, + StageExecutor stageExecutor, + OrphanExecutionContext orphanExecutionContext, + EventPublisher eventPublisher, + boolean throwExceptionIfCannotObtainLock, + Runnable finalizer) { this.runnerId = runnerId; this.executionPlanner = executionPlanner; this.stageExecutor = stageExecutor; @@ -84,6 +85,7 @@ public ExecuteOperation(RunnerId runnerId, this.finalizer = finalizer; } + protected abstract boolean validateOnlyMode(); @Override public ExecuteResult execute(ExecuteArgs args) { @@ -91,12 +93,11 @@ public ExecuteResult execute(ExecuteArgs args) { try { result = this.execute(args.getPipeline()); } catch (OperationException operationException) { - result = operationException.getResult(); throw operationException; } catch (Throwable throwable) { throw processAndGetFlamingockException(throwable, null); } finally { - finalizer.run(); + this.finalizer.run(); } return new ExecuteResult(result); } @@ -130,6 +131,9 @@ private ExecuteResponseData execute(LoadedPipeline pipeline) throws FlamingockEx execution.validate(); if (execution.isExecutionRequired()) { + if (validateOnlyMode()) { + throw new PendingChangesException(); + } execution.applyOnEach((executionId, lock, executableStage) -> { StageResult stageResult = runStage(executionId, lock, executableStage); resultBuilder.addStage(stageResult); @@ -208,5 +212,4 @@ private FlamingockException processAndGetFlamingockException(Throwable exception eventPublisher.publish(new PipelineFailedEvent(flamingockException)); return flamingockException; } - } diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationFactory.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationResolver.java similarity index 80% rename from core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationFactory.java rename to core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationResolver.java index 308c2f1dd..c43be9dab 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationFactory.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationResolver.java @@ -16,6 +16,7 @@ package io.flamingock.internal.core.operation; import io.flamingock.internal.common.core.context.ContextResolver; +import io.flamingock.internal.common.core.operation.OperationType; import io.flamingock.internal.common.core.recovery.Resolution; import io.flamingock.internal.core.builder.args.FlamingockArguments; import io.flamingock.internal.core.configuration.core.CoreConfigurable; @@ -28,15 +29,14 @@ import io.flamingock.internal.core.operation.audit.AuditListArgs; import io.flamingock.internal.core.operation.audit.AuditListOperation; import io.flamingock.internal.core.operation.audit.AuditListResult; -import io.flamingock.internal.core.operation.execute.ExecuteArgs; -import io.flamingock.internal.core.operation.execute.ExecuteOperation; -import io.flamingock.internal.core.operation.execute.ExecuteResult; +import io.flamingock.internal.core.operation.execute.*; import io.flamingock.internal.core.operation.issue.IssueGetArgs; import io.flamingock.internal.core.operation.issue.IssueGetOperation; import io.flamingock.internal.core.operation.issue.IssueGetResult; import io.flamingock.internal.core.operation.issue.IssueListArgs; import io.flamingock.internal.core.operation.issue.IssueListOperation; import io.flamingock.internal.core.operation.issue.IssueListResult; +import io.flamingock.internal.core.operation.validate.ValidateOperation; import io.flamingock.internal.core.pipeline.execution.OrphanExecutionContext; import io.flamingock.internal.core.pipeline.execution.StageExecutor; import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; @@ -46,7 +46,7 @@ import java.util.Set; -public class OperationFactory { +public class OperationResolver { private static final String ARG_HISTORY = "flamingock.audit.history"; private static final String ARG_SINCE = "flamingock.audit.since"; @@ -68,7 +68,7 @@ public class OperationFactory { private final boolean isThrowExceptionIfCannotObtainLock; private final Runnable finalizer; - public OperationFactory(RunnerId runnerId, + public OperationResolver(RunnerId runnerId, FlamingockArguments flamingockArgs, LoadedPipeline pipeline, AuditPersistence persistence, @@ -95,9 +95,14 @@ public OperationFactory(RunnerId runnerId, } public RunnableOperation getOperation() { - switch (flamingockArgs.getOperation()) { + OperationType operationType = flamingockArgs.getOperation().orElse( + coreConfiguration.isValidationOnly() ? OperationType.VALIDATE : OperationType.EXECUTE_APPLY + ); + switch (operationType) { case EXECUTE_APPLY: - return getExecuteOperation(); + return getExecuteApplyOperation(); + case VALIDATE: + return getValidateOperation(); case AUDIT_LIST: return getAuditListOperation(); case AUDIT_FIX: @@ -107,7 +112,7 @@ public OperationFactory(RunnerId runnerId, case ISSUE_GET: return getIssueGetOperation(); default: - throw new UnsupportedOperationException(String.format("Operation %s not supported", flamingockArgs.getOperation())); + throw new UnsupportedOperationException(String.format("Operation %s not supported", operationType)); } } @@ -140,17 +145,30 @@ private RunnableOperation getIssueGetOperation() { return new RunnableOperation<>(issueGetOperation, new IssueGetArgs(changeId, guidance)); } - private RunnableOperation getExecuteOperation() { + private RunnableOperation getExecuteApplyOperation() { final StageExecutor stageExecutor = new StageExecutor(dependencyContext, nonGuardedTypes, persistence, targetSystemManager, null); - ExecuteOperation executeOperation = new ExecuteOperation( - runnerId, - executionPlanner, - stageExecutor, - buildExecutionContext(coreConfiguration), - eventPublisher, - isThrowExceptionIfCannotObtainLock, - finalizer); - return new RunnableOperation<>(executeOperation, new ExecuteArgs(pipeline)); + ExecuteApplyOperation executeApplyOperation = new ExecuteApplyOperation( + runnerId, + executionPlanner, + stageExecutor, + buildExecutionContext(coreConfiguration), + eventPublisher, + isThrowExceptionIfCannotObtainLock, + finalizer); + return new RunnableOperation<>(executeApplyOperation, new ExecuteArgs(pipeline)); + } + + private RunnableOperation getValidateOperation() { + final StageExecutor stageExecutor = new StageExecutor(dependencyContext, nonGuardedTypes, persistence, targetSystemManager, null); + ValidateOperation validateOperation = new ValidateOperation( + runnerId, + executionPlanner, + stageExecutor, + buildExecutionContext(coreConfiguration), + eventPublisher, + isThrowExceptionIfCannotObtainLock, + finalizer); + return new RunnableOperation<>(validateOperation, new ExecuteArgs(pipeline)); } private static OrphanExecutionContext buildExecutionContext(CoreConfigurable configuration) { diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteApplyOperation.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteApplyOperation.java new file mode 100644 index 000000000..7cca98b39 --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/execute/ExecuteApplyOperation.java @@ -0,0 +1,43 @@ +/* + * 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.operation.execute; + +import io.flamingock.internal.core.event.EventPublisher; +import io.flamingock.internal.core.operation.AbstractPipelineTraverseOperation; +import io.flamingock.internal.core.pipeline.execution.*; +import io.flamingock.internal.core.plan.ExecutionPlanner; +import io.flamingock.internal.util.id.RunnerId; + +/** + * Executes the pipeline and returns structured result data. + */ +public class ExecuteApplyOperation extends AbstractPipelineTraverseOperation { + + public ExecuteApplyOperation(RunnerId runnerId, + ExecutionPlanner executionPlanner, + StageExecutor stageExecutor, + OrphanExecutionContext orphanExecutionContext, + EventPublisher eventPublisher, + boolean throwExceptionIfCannotObtainLock, + Runnable finalizer) { + super(runnerId, executionPlanner, stageExecutor, orphanExecutionContext, eventPublisher, throwExceptionIfCannotObtainLock, finalizer); + } + + @Override + protected boolean validateOnlyMode() { + return false; + } +} diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/validate/ValidateOperation.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/validate/ValidateOperation.java new file mode 100644 index 000000000..e5927eb3e --- /dev/null +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/validate/ValidateOperation.java @@ -0,0 +1,46 @@ +/* + * 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.operation.validate; + +import io.flamingock.internal.common.core.error.PendingChangesException; +import io.flamingock.internal.core.event.EventPublisher; +import io.flamingock.internal.core.operation.AbstractPipelineTraverseOperation; +import io.flamingock.internal.core.pipeline.execution.OrphanExecutionContext; +import io.flamingock.internal.core.pipeline.execution.StageExecutor; +import io.flamingock.internal.core.plan.ExecutionPlanner; +import io.flamingock.internal.util.id.RunnerId; + +/** + * Validates the pipeline without executing any changes. + * If pending changes exist, throws {@link PendingChangesException}. + */ +public class ValidateOperation extends AbstractPipelineTraverseOperation { + + public ValidateOperation(RunnerId runnerId, + ExecutionPlanner executionPlanner, + StageExecutor stageExecutor, + OrphanExecutionContext orphanExecutionContext, + EventPublisher eventPublisher, + boolean throwExceptionIfCannotObtainLock, + Runnable finalizer) { + super(runnerId, executionPlanner, stageExecutor, orphanExecutionContext, eventPublisher, throwExceptionIfCannotObtainLock, finalizer); + } + + @Override + protected boolean validateOnlyMode() { + return true; + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/builder/args/FlamingockArgumentsTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/builder/args/FlamingockArgumentsTest.java index c1d617814..bbe4afdc8 100644 --- a/core/flamingock-core/src/test/java/io/flamingock/internal/core/builder/args/FlamingockArgumentsTest.java +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/builder/args/FlamingockArgumentsTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -41,8 +42,8 @@ void shouldParseAllDefinedParametersWithEqualsFormat() { FlamingockArguments arguments = FlamingockArguments.parse(args); assertTrue(arguments.isCliMode()); - assertTrue(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation().get()); assertTrue(arguments.getOutputFile().isPresent()); assertEquals("/tmp/output.json", arguments.getOutputFile().orElse(null)); assertTrue(arguments.getRemainingArgs().isEmpty()); @@ -59,7 +60,8 @@ void shouldParseAllDefinedParametersWithSpaceFormat() { FlamingockArguments arguments = FlamingockArguments.parse(args); assertTrue(arguments.isCliMode()); - assertEquals(OperationType.EXECUTE_ROLLBACK, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_ROLLBACK, arguments.getOperation().get()); assertEquals("/var/log/flamingock.log", arguments.getOutputFile().orElse(null)); } @@ -83,8 +85,8 @@ void shouldHandleNullArgs() { FlamingockArguments arguments = FlamingockArguments.parse(null); assertFalse(arguments.isCliMode()); - assertFalse(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertFalse(arguments.getOperation().isPresent()); + assertEquals(Optional.empty(), arguments.getOperation()); assertFalse(arguments.getOutputFile().isPresent()); assertTrue(arguments.getRemainingArgs().isEmpty()); } @@ -94,8 +96,8 @@ void shouldHandleEmptyArgs() { FlamingockArguments arguments = FlamingockArguments.parse(new String[0]); assertFalse(arguments.isCliMode()); - assertFalse(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertFalse(arguments.getOperation().isPresent()); + assertEquals(Optional.empty(), arguments.getOperation()); assertFalse(arguments.getOutputFile().isPresent()); assertTrue(arguments.getRemainingArgs().isEmpty()); } @@ -128,17 +130,18 @@ void shouldTreatBooleanFlagFollowedByAnotherFlagAsTrue() { FlamingockArguments arguments = FlamingockArguments.parse(args); assertTrue(arguments.isCliMode()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation().get()); } @Test - void shouldReturnDefaultOperationWhenNotProvided() { + void shouldReturnNullOperationWhenNotProvided() { String[] args = {"--flamingock.cli.mode=true"}; FlamingockArguments arguments = FlamingockArguments.parse(args); - assertFalse(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertFalse(arguments.getOperation().isPresent()); + assertNull(arguments.getOperation().orElse(null)); } @Test @@ -148,8 +151,8 @@ void shouldParseAllValidOperationTypes() { FlamingockArguments arguments = FlamingockArguments.parse(args); - assertTrue(arguments.isOperationProvided()); - assertEquals(expectedType, arguments.getOperation(), + assertTrue(arguments.getOperation().isPresent()); + assertEquals(expectedType, arguments.getOperation().get(), "Failed to parse operation type: " + expectedType); } } @@ -160,7 +163,8 @@ void shouldParseOperationTypeCaseInsensitively() { FlamingockArguments arguments = FlamingockArguments.parse(args); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation().get()); } @Test @@ -275,7 +279,8 @@ void shouldHandleMixedFormats() { FlamingockArguments arguments = FlamingockArguments.parse(args); assertTrue(arguments.isCliMode()); - assertEquals(OperationType.EXECUTE_DRYRUN, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_DRYRUN, arguments.getOperation().get()); assertEquals("/output.json", arguments.getOutputFile().orElse(null)); assertEquals("customValue", arguments.getRemainingArgs().get("custom.prop")); } @@ -299,18 +304,8 @@ void shouldReturnIsOperationProvidedTrueWhenOperationExplicitlyPassed() { FlamingockArguments arguments = FlamingockArguments.parse(args); - assertTrue(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); - } - - @Test - void shouldReturnIsOperationProvidedFalseWhenUsingDefault() { - String[] args = {"--flamingock.cli.mode=true"}; - - FlamingockArguments arguments = FlamingockArguments.parse(args); - - assertFalse(arguments.isOperationProvided()); - assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation()); + assertTrue(arguments.getOperation().isPresent()); + assertEquals(OperationType.EXECUTE_APPLY, arguments.getOperation().get()); } // ========== Typed Accessor Methods Tests ========== diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteOperationTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteApplyOperationTest.java similarity index 96% rename from core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteOperationTest.java rename to core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteApplyOperationTest.java index f8842393a..869b62d74 100644 --- a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteOperationTest.java +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ExecuteApplyOperationTest.java @@ -21,8 +21,8 @@ import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.common.core.response.data.StageStatus; import io.flamingock.internal.core.event.EventPublisher; +import io.flamingock.internal.core.operation.execute.ExecuteApplyOperation; import io.flamingock.internal.core.operation.execute.ExecuteArgs; -import io.flamingock.internal.core.operation.execute.ExecuteOperation; import io.flamingock.internal.core.operation.execute.ExecuteResult; import io.flamingock.internal.core.pipeline.execution.OrphanExecutionContext; import io.flamingock.internal.core.pipeline.execution.StageExecutionException; @@ -48,9 +48,9 @@ import static org.mockito.Mockito.*; /** - * Tests for ExecuteOperation - executes the pipeline and returns structured result data. + * Tests for ExecuteApplyOperation - executes the pipeline and returns structured result data. */ -class ExecuteOperationTest { +class ExecuteApplyOperationTest { @Mock private ExecutionPlanner executionPlanner; @@ -70,7 +70,7 @@ class ExecuteOperationTest { @Mock private AbstractLoadedTask loadedTask; - private ExecuteOperation operation; + private ExecuteApplyOperation operation; private RunnerId runnerId; private OrphanExecutionContext orphanContext; private Runnable noOpFinalizer; @@ -82,7 +82,7 @@ void setUp() { orphanContext = new OrphanExecutionContext("localhost", new HashMap<>()); noOpFinalizer = () -> {}; - operation = new ExecuteOperation( + operation = new ExecuteApplyOperation( runnerId, executionPlanner, stageExecutor, diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/OperationResolverTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/OperationResolverTest.java new file mode 100644 index 000000000..d07c113f2 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/OperationResolverTest.java @@ -0,0 +1,178 @@ +/* + * 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.operation; + +import io.flamingock.internal.common.core.context.ContextResolver; +import io.flamingock.internal.core.builder.args.FlamingockArguments; +import io.flamingock.internal.core.configuration.core.CoreConfigurable; +import io.flamingock.internal.core.event.EventPublisher; +import io.flamingock.internal.core.external.store.audit.AuditPersistence; +import io.flamingock.internal.core.external.targets.TargetSystemManager; +import io.flamingock.internal.core.operation.validate.ValidateOperation; +import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; +import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage; +import io.flamingock.internal.core.plan.ExecutionPlanner; +import io.flamingock.internal.core.task.loaded.AbstractLoadedTask; +import io.flamingock.internal.common.core.operation.OperationType; +import io.flamingock.internal.util.id.RunnerId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +/** + * Tests for OperationResolver — routing logic for creating the appropriate operation. + */ +class OperationResolverTest { + + @Mock + private FlamingockArguments flamingockArgs; + + @Mock + private LoadedPipeline pipeline; + + @Mock + private AuditPersistence persistence; + + @Mock + private ExecutionPlanner executionPlanner; + + @Mock + private TargetSystemManager targetSystemManager; + + @Mock + private CoreConfigurable coreConfiguration; + + @Mock + private EventPublisher eventPublisher; + + @Mock + private ContextResolver dependencyContext; + + @Mock + private AbstractLoadedStage loadedStage; + + @Mock + private AbstractLoadedTask loadedTask; + + private RunnerId runnerId; + private Runnable noOpFinalizer; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + runnerId = RunnerId.fromString("test-runner@localhost#test-uuid"); + noOpFinalizer = () -> {}; + + // Default pipeline setup so OperationResolver does not NPE on pipeline access + when(pipeline.getSystemStage()).thenReturn(java.util.Optional.empty()); + when(pipeline.getStages()).thenReturn(Collections.singletonList(loadedStage)); + when(loadedStage.getTasks()).thenReturn(Collections.singletonList(loadedTask)); + + // Default coreConfiguration stubs + when(coreConfiguration.getMetadata()).thenReturn(Collections.emptyMap()); + } + + @Test + @DisplayName("validationOnly=true with no operation → getOperation() routes to ValidateOperation") + void shouldRouteToValidateOperationWhenValidationOnlyIsTrue() throws Exception { + // Given + when(flamingockArgs.getOperation()).thenReturn(Optional.empty()); + when(coreConfiguration.isValidationOnly()).thenReturn(true); + + OperationResolver factory = new OperationResolver( + runnerId, + flamingockArgs, + pipeline, + persistence, + executionPlanner, + targetSystemManager, + coreConfiguration, + eventPublisher, + dependencyContext, + new HashSet<>(), + true, + noOpFinalizer + ); + + // When + RunnableOperation runnableOperation = factory.getOperation(); + + // Then + assertNotNull(runnableOperation); + Operation innerOperation = extractInnerOperation(runnableOperation); + assertInstanceOf(ValidateOperation.class, innerOperation, + "Expected the factory to route to ValidateOperation when validationOnly=true"); + } + + @Test + @DisplayName("validationOnly=false with EXECUTE_APPLY → getOperation() does NOT route to ValidateOperation") + void shouldNotRouteToValidateOperationWhenValidationOnlyIsFalse() throws Exception { + // Given + when(flamingockArgs.getOperation()).thenReturn(Optional.of(OperationType.EXECUTE_APPLY)); + when(coreConfiguration.isValidationOnly()).thenReturn(false); + + OperationResolver factory = new OperationResolver( + runnerId, + flamingockArgs, + pipeline, + persistence, + executionPlanner, + targetSystemManager, + coreConfiguration, + eventPublisher, + dependencyContext, + new HashSet<>(), + true, + noOpFinalizer + ); + + // When + RunnableOperation runnableOperation = factory.getOperation(); + + // Then + assertNotNull(runnableOperation); + Operation innerOperation = extractInnerOperation(runnableOperation); + // When validationOnly=false the standard AbstractPipelineTraverseOperation is used, not ValidateOperation + assertNotNull(innerOperation); + // Verify it is NOT a ValidateOperation + boolean isValidateOp = innerOperation instanceof ValidateOperation; + org.junit.jupiter.api.Assertions.assertFalse(isValidateOp, + "Expected the factory NOT to route to ValidateOperation when validationOnly=false"); + } + + // ─────────────────────────── Helpers ─────────────────────────── + + /** + * Uses reflection to extract the private {@code operation} field from a {@link RunnableOperation}. + */ + private static Operation extractInnerOperation(RunnableOperation runnableOperation) + throws NoSuchFieldException, IllegalAccessException { + Field field = RunnableOperation.class.getDeclaredField("operation"); + field.setAccessible(true); + return (Operation) field.get(runnableOperation); + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ValidateOperationTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ValidateOperationTest.java new file mode 100644 index 000000000..216e3043f --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/ValidateOperationTest.java @@ -0,0 +1,185 @@ +/* + * 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.operation; + +import io.flamingock.internal.common.core.error.PendingChangesException; +import io.flamingock.internal.common.core.response.data.ExecutionStatus; +import io.flamingock.internal.core.event.EventPublisher; +import io.flamingock.internal.core.operation.execute.ExecuteArgs; +import io.flamingock.internal.core.operation.execute.ExecuteResult; +import io.flamingock.internal.core.operation.validate.ValidateOperation; +import io.flamingock.internal.core.pipeline.execution.ExecutablePipeline; +import io.flamingock.internal.core.pipeline.execution.ExecutableStage; +import io.flamingock.internal.core.pipeline.execution.OrphanExecutionContext; +import io.flamingock.internal.core.pipeline.execution.StageExecutor; +import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; +import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage; +import io.flamingock.internal.core.plan.ExecutionPlan; +import io.flamingock.internal.core.plan.ExecutionPlanner; +import io.flamingock.internal.core.task.executable.ExecutableTask; +import io.flamingock.internal.core.task.loaded.AbstractLoadedTask; +import io.flamingock.internal.util.id.RunnerId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; + +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for ValidateOperation — validation-only mode that checks for pending changes + * without executing them. + */ +class ValidateOperationTest { + + @Mock + private ExecutionPlanner executionPlanner; + + @Mock + private StageExecutor stageExecutor; + + @Mock + private EventPublisher eventPublisher; + + @Mock + private LoadedPipeline pipeline; + + @Mock + private AbstractLoadedStage loadedStage; + + @Mock + private AbstractLoadedTask loadedTask; + + private ValidateOperation operation; + private RunnerId runnerId; + private OrphanExecutionContext orphanContext; + private Runnable noOpFinalizer; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + runnerId = RunnerId.fromString("test-runner@localhost#test-uuid"); + orphanContext = new OrphanExecutionContext("localhost", new HashMap<>()); + noOpFinalizer = () -> {}; + + operation = new ValidateOperation( + runnerId, + executionPlanner, + stageExecutor, + orphanContext, + eventPublisher, + true, + noOpFinalizer + ); + } + + @Test + @DisplayName("validationOnly: no pending changes → execute() returns success without throwing") + void shouldReturnSuccessWhenNoPendingChangesExist() throws Exception { + // Given + ExecutionPlan executionPlan = mockNoPendingPlan(); + + when(pipeline.getSystemStage()).thenReturn(java.util.Optional.empty()); + when(pipeline.getStages()).thenReturn(Collections.singletonList(loadedStage)); + when(loadedStage.getTasks()).thenReturn(Collections.singletonList(loadedTask)); + when(executionPlanner.getNextExecution(any())).thenReturn(executionPlan); + + ExecuteArgs args = new ExecuteArgs(pipeline); + + // When + ExecuteResult result = operation.execute(args); + + // Then + assertNotNull(result); + assertNotNull(result.getData()); + // ValidateOperation uses resultBuilder.noChanges() — status is SUCCESS when no pending changes + assertEquals(ExecutionStatus.SUCCESS, result.getData().getStatus()); + } + + @Test + @DisplayName("validationOnly: pending changes exist → execute() throws PendingChangesException with correct count") + void shouldThrowPendingChangesExceptionWhenPendingChangesExist() throws Exception { + // Given + // Two pending tasks (isAlreadyApplied = false) + ExecutableTask pendingTask1 = mock(ExecutableTask.class); + ExecutableTask pendingTask2 = mock(ExecutableTask.class); + when(pendingTask1.isAlreadyApplied()).thenReturn(false); + when(pendingTask2.isAlreadyApplied()).thenReturn(false); + + List pendingTasks = Arrays.asList(pendingTask1, pendingTask2); + ExecutableStage executableStage = mock(ExecutableStage.class); + doReturn(pendingTasks).when(executableStage).getTasks(); + + ExecutablePipeline executablePipeline = mock(ExecutablePipeline.class); + when(executablePipeline.getExecutableStages()).thenReturn(Collections.singletonList(executableStage)); + + ExecutionPlan executionPlan = mockPendingPlan(executablePipeline); + + when(pipeline.getSystemStage()).thenReturn(java.util.Optional.empty()); + when(pipeline.getStages()).thenReturn(Collections.singletonList(loadedStage)); + when(loadedStage.getTasks()).thenReturn(Arrays.asList(loadedTask, loadedTask)); + when(executionPlanner.getNextExecution(any())).thenReturn(executionPlan); + + ExecuteArgs args = new ExecuteArgs(pipeline); + + // When / Then + PendingChangesException thrown = assertThrows( + PendingChangesException.class, + () -> operation.execute(args) + ); + } + + // ─────────────────────────── Helpers ─────────────────────────── + + /** + * Creates an ExecutionPlan mock where no execution is required + * (i.e., all changes are already applied). + */ + private ExecutionPlan mockNoPendingPlan() { + ExecutionPlan plan = mock(ExecutionPlan.class); + when(plan.isExecutionRequired()).thenReturn(false); + doNothing().when(plan).validate(); + doNothing().when(plan).close(); + return plan; + } + + /** + * Creates an ExecutionPlan mock where execution is required + * (i.e., there are pending changes) and the pipeline exposes the given executable stages. + */ + private ExecutionPlan mockPendingPlan(ExecutablePipeline executablePipeline) { + ExecutionPlan plan = mock(ExecutionPlan.class); + when(plan.isExecutionRequired()).thenReturn(true); + when(plan.getPipeline()).thenReturn(executablePipeline); + doNothing().when(plan).validate(); + doNothing().when(plan).close(); + return plan; + } +} diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java index 477336294..e13dd00dd 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java @@ -95,6 +95,11 @@ public void setEnabled(boolean enabled) { coreConfiguration.setEnabled(enabled); } + @Override + public void setValidationOnly(boolean validationOnly) { + coreConfiguration.setValidationOnly(validationOnly); + } + @Override public void setServiceIdentifier(String serviceIdentifier) { coreConfiguration.setServiceIdentifier(serviceIdentifier); @@ -131,6 +136,11 @@ public boolean isEnabled() { return coreConfiguration.isEnabled(); } + @Override + public boolean isValidationOnly() { + return coreConfiguration.isValidationOnly(); + } + @Override public String getServiceIdentifier() { return coreConfiguration.getServiceIdentifier(); diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/ValidationOnlyIntegrationTest.java b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/ValidationOnlyIntegrationTest.java new file mode 100644 index 000000000..d9bfebae6 --- /dev/null +++ b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/ValidationOnlyIntegrationTest.java @@ -0,0 +1,197 @@ +/* + * 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.springboot; + +import io.flamingock.api.external.TargetSystem; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.error.PendingChangesException; +import io.flamingock.internal.core.external.store.CommunityAuditStore; +import io.flamingock.internal.core.external.store.audit.community.CommunityAuditPersistence; +import io.flamingock.internal.core.external.store.lock.LockAcquisition; +import io.flamingock.internal.core.external.store.lock.community.CommunityLockService; +import io.flamingock.internal.util.id.RunnerId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Spring Boot end-to-end integration tests for the {@code validationOnly} mode. + * + *

Uses {@code INITIALIZING_BEAN} management mode so that Flamingock runs synchronously + * during application context startup — enabling the context to fail fast when pending changes + * are detected. + */ +class ValidationOnlyIntegrationTest { + + private static final String CHANGE_ID = "validation-only-test-change"; + + /** + * Builds an ApplicationContextRunner pre-configured with INITIALIZING_BEAN mode + * and validation-only enabled, wired to a specific audit configuration. + */ + private ApplicationContextRunner contextRunner(Class configClass) { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FlamingockAutoConfiguration.class)) + .withUserConfiguration(configClass) + .withPropertyValues( + "spring.profiles.active=non-cli", + "flamingock.management-mode=INITIALIZING_BEAN", + "flamingock.validation-only=true" + ); + } + + @Test + @DisplayName("validationOnly=true + pending changes → context fails with PendingChangesException") + void whenValidationOnlyAndPendingChanges_thenContextFailsWithPendingChangesException() { + contextRunner(PendingChangesConfiguration.class).run(ctx -> { + assertThat(ctx).hasFailed(); + + Throwable failure = ctx.getStartupFailure(); + Throwable rootCause = getRootCause(failure); + + assertThat(rootCause) + .isInstanceOf(PendingChangesException.class); + }); + } + + @Test + @DisplayName("validationOnly=true + all changes applied → context starts successfully") + void whenValidationOnlyAndAllChangesApplied_thenContextStartsSuccessfully() { + contextRunner(AllChangesAppliedConfiguration.class).run(ctx -> { + assertThat(ctx).hasNotFailed(); + }); + } + + // ─────────────────────────── Helpers ─────────────────────────── + + private static Throwable getRootCause(Throwable throwable) { + Throwable cause = throwable; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + /** + * Creates a fully-stubbed {@link CommunityAuditStore} mock. + * Both {@link CommunityAuditPersistence} and {@link CommunityLockService} are mocked + * and wired into the store. + * + * @param auditHistory the audit history to return from {@code getAuditHistory()} + */ + private static CommunityAuditStore buildAuditStoreMock(List auditHistory) { + CommunityAuditPersistence persistence = mock(CommunityAuditPersistence.class); + CommunityLockService lockService = mock(CommunityLockService.class); + + // Stub audit history — determines whether changes are "pending" + when(persistence.getAuditHistory()).thenReturn(auditHistory); + + // Delegate snapshot to the default interface method (builds from getAuditHistory()) + when(persistence.getAuditSnapshotByChangeId()).thenCallRealMethod(); + + // Stub closer — called by both the operation and the runner finalizer + when(persistence.getCloser()).thenReturn(() -> { }); + + // Stub lock acquisition — required when there are pending changes + when(lockService.upsert(any(), any(RunnerId.class), anyLong())) + .thenAnswer(invocation -> new LockAcquisition( + invocation.getArgument(1), + invocation.getArgument(2) + )); + + // Stub lock extension — called by the LockRefreshDaemon thread + when(lockService.extendLock(any(), any(RunnerId.class), anyLong())) + .thenAnswer(invocation -> new LockAcquisition( + invocation.getArgument(1), + invocation.getArgument(2) + )); + + CommunityAuditStore auditStore = mock(CommunityAuditStore.class); + when(auditStore.getPersistence()).thenReturn(persistence); + when(auditStore.getLockService()).thenReturn(lockService); + + return auditStore; + } + + // ─────────────────────────── Spring Configurations ─────────────────────────── + + /** + * Configuration for the "pending changes" scenario. + * The audit history is empty → all pipeline changes are considered pending. + */ + @Configuration + static class PendingChangesConfiguration { + + @Bean + public List targetSystems() { + return new ArrayList<>(); + } + + @Bean + public CommunityAuditStore auditStore() { + return buildAuditStoreMock(Collections.emptyList()); + } + } + + /** + * Configuration for the "all changes applied" scenario. + * The audit history contains an APPLIED entry for the test change → no execution needed. + */ + @Configuration + static class AllChangesAppliedConfiguration { + + @Bean + public List targetSystems() { + return new ArrayList<>(); + } + + @Bean + public CommunityAuditStore auditStore() { + AuditEntry appliedEntry = new AuditEntry( + "exec-id-001", + "test-stage", + CHANGE_ID, + "test", + LocalDateTime.now().minusMinutes(5), + AuditEntry.Status.APPLIED, + AuditEntry.ChangeType.STANDARD_CODE, + "io.flamingock.springboot.test._001__ValidationOnlyTestChange", + "apply", + null, + 100L, + "localhost", + null, + false, + null + ); + return buildAuditStoreMock(Collections.singletonList(appliedEntry)); + } + } +} diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/test/_001__ValidationOnlyTestChange.java b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/test/_001__ValidationOnlyTestChange.java new file mode 100644 index 000000000..5434131b6 --- /dev/null +++ b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/test/_001__ValidationOnlyTestChange.java @@ -0,0 +1,34 @@ +/* + * 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.springboot.test; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.TargetSystem; + +/** + * A simple no-op change class used in validation-only integration tests. + * It is never actually executed; it only exists to populate the pipeline for testing. + */ +@Change(id = "validation-only-test-change", transactional = false, author = "test") +@TargetSystem(id = "test-system") +public class _001__ValidationOnlyTestChange { + + @Apply + public void apply() { + // No-op: this change is never executed in validation-only mode + } +} diff --git a/platform-plugins/flamingock-springboot-integration/src/test/resources/META-INF/flamingock/metadata.json b/platform-plugins/flamingock-springboot-integration/src/test/resources/META-INF/flamingock/metadata.json index f3c4a0d76..98e73ffec 100644 --- a/platform-plugins/flamingock-springboot-integration/src/test/resources/META-INF/flamingock/metadata.json +++ b/platform-plugins/flamingock-springboot-integration/src/test/resources/META-INF/flamingock/metadata.json @@ -1,12 +1,38 @@ { - "setup": "DEFAULT", - "stages": [ - { - "name": "test-stage", - "type": "USER", - "sourcesPackage": "io.flamingock.springboot.test", - "tasks": [], - "parallel": false - } - ] -} \ No newline at end of file + "pipeline" : { + "stages" : [ { + "name" : "test-stage", + "type" : "DEFAULT", + "sourcesPackage" : "io.flamingock.springboot.test", + "tasks" : [ { + "type" : "codePreviewChange", + "id" : "validation-only-test-change", + "order" : "001", + "author" : "test", + "source" : "io.flamingock.springboot.test._001__ValidationOnlyTestChange", + "runAlways" : false, + "transactionalFlag" : false, + "system" : false, + "targetSystem" : { + "id" : "test-system" + }, + "recovery" : { + "strategy" : "MANUAL_INTERVENTION", + "alwaysRetry" : false + }, + "legacy" : false, + "previewConstructor" : { + "parameterTypes" : [ ] + }, + "applyPreviewMethod" : { + "name" : "apply", + "parameterTypes" : [ ] + }, + "standard" : true, + "sortable" : true + } ] + } ] + }, + "properties" : { }, + "pipelineFile" : "" +}