Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.error;

/**
* Exception thrown when Flamingock runs in validation-only mode and detects pending changes.
*/
public class PendingChangesException extends FlamingockException {

private final int pendingCount;

public PendingChangesException(int pendingCount) {
super("Flamingock validationOnly=true: %d pending change(s) detected. Apply them before running in validation-only mode.", pendingCount);
this.pendingCount = pendingCount;
}

public int getPendingCount() {
return pendingCount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;

class PendingChangesExceptionTest {

@Test
@DisplayName("Should return the pending count passed to the constructor")
void shouldReturnPendingCount() {
// Given
int pendingCount = 5;

// When
PendingChangesException exception = new PendingChangesException(pendingCount);

// Then
assertEquals(pendingCount, exception.getPendingCount());
}

@Test
@DisplayName("Should include the pending count in the exception message")
void shouldIncludeCountInMessage() {
// Given
int pendingCount = 5;

// When
PendingChangesException exception = new PendingChangesException(pendingCount);

// Then
assertTrue(exception.getMessage().contains("5"),
"Message should contain the pending count as a string");
}

@Test
@DisplayName("Should be an instance of FlamingockException")
void shouldExtendFlamingockException() {
// Given / When
PendingChangesException exception = new PendingChangesException(3);

// Then
assertInstanceOf(FlamingockException.class, exception);
}

@Test
@DisplayName("Should work correctly with zero pending changes")
void shouldWorkWithZeroPendingCount() {
// Given / When
PendingChangesException exception = new PendingChangesException(0);

// Then
assertEquals(0, exception.getPendingCount());
assertTrue(exception.getMessage().contains("0"));
}

@Test
@DisplayName("Should work correctly with a large pending count")
void shouldWorkWithLargePendingCount() {
// Given
int largeCount = 999;

// When
PendingChangesException exception = new PendingChangesException(largeCount);

// Then
assertEquals(largeCount, exception.getPendingCount());
assertTrue(exception.getMessage().contains("999"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public interface CoreConfigurable {

void setEnabled(boolean enabled);

void setValidationOnly(boolean validationOnly);

boolean isValidationOnly();
Comment thread
bercianor marked this conversation as resolved.

void setServiceIdentifier(String serviceIdentifier);

void setMetadata(Map<String, Object> metadata);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public class CoreConfiguration implements CoreConfigurable {
*/
private boolean enabled = true;

/**
* If true, Flamingock will only validate that no pending changes exist without applying them. Default false
*/
private boolean validationOnly = false;

/**
* Service identifier.
*/
Expand Down Expand Up @@ -91,6 +96,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;
Expand Down Expand Up @@ -126,6 +136,11 @@ public boolean isEnabled() {
return enabled;
}

@Override
public boolean isValidationOnly() {
return validationOnly;
}

@Override
public String getServiceIdentifier() {
return serviceIdentifier;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@
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.ValidateOperation;
import io.flamingock.internal.core.operation.issue.IssueGetArgs;
import io.flamingock.internal.core.operation.issue.IssueGetOperation;
import io.flamingock.internal.core.operation.issue.IssueGetResult;
Expand Down Expand Up @@ -95,9 +97,15 @@ public OperationFactory(RunnerId runnerId,
}

public RunnableOperation<?, ?> getOperation() {
switch (flamingockArgs.getOperation()) {
OperationType operationType = flamingockArgs.getOperation();
if (operationType == OperationType.EXECUTE_APPLY && coreConfiguration.isValidationOnly()) {
operationType = OperationType.EXECUTE_VALIDATE;
Comment thread
bercianor marked this conversation as resolved.
Outdated
}
switch (operationType) {
case EXECUTE_APPLY:
return getExecuteOperation();
case EXECUTE_VALIDATE:
Comment thread
bercianor marked this conversation as resolved.
Outdated
return getValidateOperation();
case AUDIT_LIST:
return getAuditListOperation();
case AUDIT_FIX:
Expand All @@ -107,7 +115,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));
}
}

Expand Down Expand Up @@ -153,6 +161,16 @@ private RunnableOperation<ExecuteArgs, ExecuteResult> getExecuteOperation() {
return new RunnableOperation<>(executeOperation, new ExecuteArgs(pipeline));
}

private RunnableOperation<ExecuteArgs, ExecuteResult> getValidateOperation() {
ValidateOperation validateOperation = new ValidateOperation(
runnerId,
executionPlanner,
eventPublisher,
isThrowExceptionIfCannotObtainLock,
finalizer);
return new RunnableOperation<>(validateOperation, new ExecuteArgs(pipeline));
}

private static OrphanExecutionContext buildExecutionContext(CoreConfigurable configuration) {
return new OrphanExecutionContext(StringUtil.hostname(), configuration.getMetadata());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* 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.common.core.error.FlamingockException;
import io.flamingock.internal.common.core.error.PendingChangesException;
import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
import io.flamingock.internal.core.event.EventPublisher;
import io.flamingock.internal.core.event.model.impl.PipelineCompletedEvent;
import io.flamingock.internal.core.event.model.impl.PipelineFailedEvent;
import io.flamingock.internal.core.event.model.impl.PipelineStartedEvent;
import io.flamingock.internal.core.operation.Operation;
import io.flamingock.internal.core.operation.result.ExecutionResultBuilder;
import io.flamingock.internal.core.pipeline.execution.ExecutableStage;
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.external.store.lock.LockException;
import io.flamingock.internal.core.task.executable.ExecutableTask;
import io.flamingock.internal.util.id.RunnerId;
import io.flamingock.internal.util.log.FlamingockLoggerFactory;
import org.slf4j.Logger;

import java.util.ArrayList;
import java.util.List;

/**
* Validates the pipeline without executing any changes.
* If pending changes exist, throws {@link PendingChangesException}.
*/
public class ValidateOperation implements Operation<ExecuteArgs, ExecuteResult> {

private static final Logger logger = FlamingockLoggerFactory.getLogger("PipelineRunner");

private final RunnerId runnerId;

private final ExecutionPlanner executionPlanner;

private final EventPublisher eventPublisher;

private final boolean throwExceptionIfCannotObtainLock;

private final Runnable finalizer;

public ValidateOperation(RunnerId runnerId,
ExecutionPlanner executionPlanner,
EventPublisher eventPublisher,
boolean throwExceptionIfCannotObtainLock,
Runnable finalizer) {
this.runnerId = runnerId;
this.executionPlanner = executionPlanner;
this.eventPublisher = eventPublisher;
this.throwExceptionIfCannotObtainLock = throwExceptionIfCannotObtainLock;
this.finalizer = finalizer;
}

@Override
public ExecuteResult execute(ExecuteArgs args) {
ExecuteResponseData result;
try {
result = this.validate(args.getPipeline());
} catch (FlamingockException flamingockException) {
throw flamingockException;
} catch (Throwable throwable) {
throw new FlamingockException(throwable);
} finally {
finalizer.run();
}
return new ExecuteResult(result);
}

private static List<AbstractLoadedStage> validateAndGetExecutableStages(LoadedPipeline pipeline) {
pipeline.validate();
List<AbstractLoadedStage> stages = new ArrayList<>();
if (pipeline.getSystemStage().isPresent()) {
stages.add(pipeline.getSystemStage().get());
}
stages.addAll(pipeline.getStages());
return stages;
}

private ExecuteResponseData validate(LoadedPipeline pipeline) throws FlamingockException {
List<AbstractLoadedStage> allStages = validateAndGetExecutableStages(pipeline);
int stageCount = allStages.size();
long changeCount = allStages.stream()
.mapToLong(stage -> stage.getTasks().size())
.sum();
logger.info("Flamingock validation started [stages={} changes={}]", stageCount, changeCount);

eventPublisher.publish(new PipelineStartedEvent());
ExecutionResultBuilder resultBuilder = new ExecutionResultBuilder().startTimer();

do {
List<AbstractLoadedStage> stages = validateAndGetExecutableStages(pipeline);
try (ExecutionPlan execution = executionPlanner.getNextExecution(stages)) {
execution.validate();

if (execution.isExecutionRequired()) {
int pendingCount = countPendingTasks(execution);
throw new PendingChangesException(pendingCount);
} else {
break;
}
} catch (LockException exception) {
eventPublisher.publish(new PipelineFailedEvent(exception));
if (throwExceptionIfCannotObtainLock) {
logger.debug("Required process lock not acquired - ABORTING VALIDATION", exception);
throw exception;
} else {
logger.warn("Process lock not acquired but throwExceptionIfCannotObtainLock=false - CONTINUING WITHOUT LOCK", exception);
}
break;
}
} while (true);

resultBuilder.stopTimer().noChanges();
ExecuteResponseData result = resultBuilder.build();

logger.info("Flamingock validation completed — no pending changes detected");
eventPublisher.publish(new PipelineCompletedEvent());

return result;
}

private static int countPendingTasks(ExecutionPlan execution) {
int count = 0;
for (ExecutableStage stage : execution.getPipeline().getExecutableStages()) {
for (ExecutableTask task : stage.getTasks()) {
if (!task.isAlreadyApplied()) {
count++;
}
}
}
return count;
}
Comment thread
bercianor marked this conversation as resolved.
Outdated
}
Loading
Loading