Skip to content

Commit 01a2c58

Browse files
committed
feat(core): add validationOnly mode to prevent change execution
1 parent abcfe6b commit 01a2c58

File tree

13 files changed

+982
-13
lines changed

13 files changed

+982
-13
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.common.core.error;
17+
18+
/**
19+
* Exception thrown when Flamingock runs in validation-only mode and detects pending changes.
20+
*/
21+
public class PendingChangesException extends FlamingockException {
22+
23+
private final int pendingCount;
24+
25+
public PendingChangesException(int pendingCount) {
26+
super("Flamingock validationOnly=true: %d pending change(s) detected. Apply them before running in validation-only mode.", pendingCount);
27+
this.pendingCount = pendingCount;
28+
}
29+
30+
public int getPendingCount() {
31+
return pendingCount;
32+
}
33+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.common.core.error;
17+
18+
import org.junit.jupiter.api.DisplayName;
19+
import org.junit.jupiter.api.Test;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
23+
import static org.junit.jupiter.api.Assertions.assertTrue;
24+
25+
class PendingChangesExceptionTest {
26+
27+
@Test
28+
@DisplayName("Should return the pending count passed to the constructor")
29+
void shouldReturnPendingCount() {
30+
// Given
31+
int pendingCount = 5;
32+
33+
// When
34+
PendingChangesException exception = new PendingChangesException(pendingCount);
35+
36+
// Then
37+
assertEquals(pendingCount, exception.getPendingCount());
38+
}
39+
40+
@Test
41+
@DisplayName("Should include the pending count in the exception message")
42+
void shouldIncludeCountInMessage() {
43+
// Given
44+
int pendingCount = 5;
45+
46+
// When
47+
PendingChangesException exception = new PendingChangesException(pendingCount);
48+
49+
// Then
50+
assertTrue(exception.getMessage().contains("5"),
51+
"Message should contain the pending count as a string");
52+
}
53+
54+
@Test
55+
@DisplayName("Should be an instance of FlamingockException")
56+
void shouldExtendFlamingockException() {
57+
// Given / When
58+
PendingChangesException exception = new PendingChangesException(3);
59+
60+
// Then
61+
assertInstanceOf(FlamingockException.class, exception);
62+
}
63+
64+
@Test
65+
@DisplayName("Should work correctly with zero pending changes")
66+
void shouldWorkWithZeroPendingCount() {
67+
// Given / When
68+
PendingChangesException exception = new PendingChangesException(0);
69+
70+
// Then
71+
assertEquals(0, exception.getPendingCount());
72+
assertTrue(exception.getMessage().contains("0"));
73+
}
74+
75+
@Test
76+
@DisplayName("Should work correctly with a large pending count")
77+
void shouldWorkWithLargePendingCount() {
78+
// Given
79+
int largeCount = 999;
80+
81+
// When
82+
PendingChangesException exception = new PendingChangesException(largeCount);
83+
84+
// Then
85+
assertEquals(largeCount, exception.getPendingCount());
86+
assertTrue(exception.getMessage().contains("999"));
87+
}
88+
}

core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,15 @@ public HOLDER setEnabled(boolean enabled) {
359359
return getSelf();
360360
}
361361

362+
public HOLDER setValidationOnly(boolean validationOnly) {
363+
coreConfiguration.setValidationOnly(validationOnly);
364+
return getSelf();
365+
}
366+
367+
public boolean isValidationOnly() {
368+
return coreConfiguration.isValidationOnly();
369+
}
370+
362371
@Override
363372
public HOLDER setServiceIdentifier(String serviceIdentifier) {
364373
coreConfiguration.setServiceIdentifier(serviceIdentifier);

core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public interface CoreConfigurable {
3838

3939
void setEnabled(boolean enabled);
4040

41+
void setValidationOnly(boolean validationOnly);
42+
43+
boolean isValidationOnly();
44+
4145
void setServiceIdentifier(String serviceIdentifier);
4246

4347
void setMetadata(Map<String, Object> metadata);

core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ public class CoreConfiguration implements CoreConfigurable {
3535
*/
3636
private boolean enabled = true;
3737

38+
/**
39+
* If true, Flamingock will only validate that no pending changes exist without applying them. Default false
40+
*/
41+
private boolean validationOnly = false;
42+
3843
/**
3944
* Service identifier.
4045
*/
@@ -91,6 +96,11 @@ public void setEnabled(boolean enabled) {
9196
this.enabled = enabled;
9297
}
9398

99+
@Override
100+
public void setValidationOnly(boolean validationOnly) {
101+
this.validationOnly = validationOnly;
102+
}
103+
94104
@Override
95105
public void setServiceIdentifier(String serviceIdentifier) {
96106
this.serviceIdentifier = serviceIdentifier;
@@ -126,6 +136,11 @@ public boolean isEnabled() {
126136
return enabled;
127137
}
128138

139+
@Override
140+
public boolean isValidationOnly() {
141+
return validationOnly;
142+
}
143+
129144
@Override
130145
public String getServiceIdentifier() {
131146
return serviceIdentifier;

core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/OperationFactory.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package io.flamingock.internal.core.operation;
1717

1818
import io.flamingock.internal.common.core.context.ContextResolver;
19+
import io.flamingock.internal.common.core.operation.OperationType;
1920
import io.flamingock.internal.common.core.recovery.Resolution;
2021
import io.flamingock.internal.core.builder.args.FlamingockArguments;
2122
import io.flamingock.internal.core.configuration.core.CoreConfigurable;
@@ -31,6 +32,7 @@
3132
import io.flamingock.internal.core.operation.execute.ExecuteArgs;
3233
import io.flamingock.internal.core.operation.execute.ExecuteOperation;
3334
import io.flamingock.internal.core.operation.execute.ExecuteResult;
35+
import io.flamingock.internal.core.operation.execute.ValidateOperation;
3436
import io.flamingock.internal.core.operation.issue.IssueGetArgs;
3537
import io.flamingock.internal.core.operation.issue.IssueGetOperation;
3638
import io.flamingock.internal.core.operation.issue.IssueGetResult;
@@ -95,9 +97,15 @@ public OperationFactory(RunnerId runnerId,
9597
}
9698

9799
public RunnableOperation<?, ?> getOperation() {
98-
switch (flamingockArgs.getOperation()) {
100+
OperationType operationType = flamingockArgs.getOperation();
101+
if (operationType == OperationType.EXECUTE_APPLY && coreConfiguration.isValidationOnly()) {
102+
operationType = OperationType.EXECUTE_VALIDATE;
103+
}
104+
switch (operationType) {
99105
case EXECUTE_APPLY:
100106
return getExecuteOperation();
107+
case EXECUTE_VALIDATE:
108+
return getValidateOperation();
101109
case AUDIT_LIST:
102110
return getAuditListOperation();
103111
case AUDIT_FIX:
@@ -107,7 +115,7 @@ public OperationFactory(RunnerId runnerId,
107115
case ISSUE_GET:
108116
return getIssueGetOperation();
109117
default:
110-
throw new UnsupportedOperationException(String.format("Operation %s not supported", flamingockArgs.getOperation()));
118+
throw new UnsupportedOperationException(String.format("Operation %s not supported", operationType));
111119
}
112120
}
113121

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

164+
private RunnableOperation<ExecuteArgs, ExecuteResult> getValidateOperation() {
165+
ValidateOperation validateOperation = new ValidateOperation(
166+
runnerId,
167+
executionPlanner,
168+
eventPublisher,
169+
isThrowExceptionIfCannotObtainLock,
170+
finalizer);
171+
return new RunnableOperation<>(validateOperation, new ExecuteArgs(pipeline));
172+
}
173+
156174
private static OrphanExecutionContext buildExecutionContext(CoreConfigurable configuration) {
157175
return new OrphanExecutionContext(StringUtil.hostname(), configuration.getMetadata());
158176
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.core.operation.execute;
17+
18+
import io.flamingock.internal.common.core.error.FlamingockException;
19+
import io.flamingock.internal.common.core.error.PendingChangesException;
20+
import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
21+
import io.flamingock.internal.core.event.EventPublisher;
22+
import io.flamingock.internal.core.event.model.impl.PipelineCompletedEvent;
23+
import io.flamingock.internal.core.event.model.impl.PipelineFailedEvent;
24+
import io.flamingock.internal.core.event.model.impl.PipelineStartedEvent;
25+
import io.flamingock.internal.core.operation.Operation;
26+
import io.flamingock.internal.core.operation.result.ExecutionResultBuilder;
27+
import io.flamingock.internal.core.pipeline.execution.ExecutableStage;
28+
import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline;
29+
import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage;
30+
import io.flamingock.internal.core.plan.ExecutionPlan;
31+
import io.flamingock.internal.core.plan.ExecutionPlanner;
32+
import io.flamingock.internal.core.external.store.lock.LockException;
33+
import io.flamingock.internal.core.task.executable.ExecutableTask;
34+
import io.flamingock.internal.util.id.RunnerId;
35+
import io.flamingock.internal.util.log.FlamingockLoggerFactory;
36+
import org.slf4j.Logger;
37+
38+
import java.util.ArrayList;
39+
import java.util.List;
40+
41+
/**
42+
* Validates the pipeline without executing any changes.
43+
* If pending changes exist, throws {@link PendingChangesException}.
44+
*/
45+
public class ValidateOperation implements Operation<ExecuteArgs, ExecuteResult> {
46+
47+
private static final Logger logger = FlamingockLoggerFactory.getLogger("PipelineRunner");
48+
49+
private final RunnerId runnerId;
50+
51+
private final ExecutionPlanner executionPlanner;
52+
53+
private final EventPublisher eventPublisher;
54+
55+
private final boolean throwExceptionIfCannotObtainLock;
56+
57+
private final Runnable finalizer;
58+
59+
public ValidateOperation(RunnerId runnerId,
60+
ExecutionPlanner executionPlanner,
61+
EventPublisher eventPublisher,
62+
boolean throwExceptionIfCannotObtainLock,
63+
Runnable finalizer) {
64+
this.runnerId = runnerId;
65+
this.executionPlanner = executionPlanner;
66+
this.eventPublisher = eventPublisher;
67+
this.throwExceptionIfCannotObtainLock = throwExceptionIfCannotObtainLock;
68+
this.finalizer = finalizer;
69+
}
70+
71+
@Override
72+
public ExecuteResult execute(ExecuteArgs args) {
73+
ExecuteResponseData result;
74+
try {
75+
result = this.validate(args.getPipeline());
76+
} catch (FlamingockException flamingockException) {
77+
throw flamingockException;
78+
} catch (Throwable throwable) {
79+
throw new FlamingockException(throwable);
80+
} finally {
81+
finalizer.run();
82+
}
83+
return new ExecuteResult(result);
84+
}
85+
86+
private static List<AbstractLoadedStage> validateAndGetExecutableStages(LoadedPipeline pipeline) {
87+
pipeline.validate();
88+
List<AbstractLoadedStage> stages = new ArrayList<>();
89+
if (pipeline.getSystemStage().isPresent()) {
90+
stages.add(pipeline.getSystemStage().get());
91+
}
92+
stages.addAll(pipeline.getStages());
93+
return stages;
94+
}
95+
96+
private ExecuteResponseData validate(LoadedPipeline pipeline) throws FlamingockException {
97+
List<AbstractLoadedStage> allStages = validateAndGetExecutableStages(pipeline);
98+
int stageCount = allStages.size();
99+
long changeCount = allStages.stream()
100+
.mapToLong(stage -> stage.getTasks().size())
101+
.sum();
102+
logger.info("Flamingock validation started [stages={} changes={}]", stageCount, changeCount);
103+
104+
eventPublisher.publish(new PipelineStartedEvent());
105+
ExecutionResultBuilder resultBuilder = new ExecutionResultBuilder().startTimer();
106+
107+
do {
108+
List<AbstractLoadedStage> stages = validateAndGetExecutableStages(pipeline);
109+
try (ExecutionPlan execution = executionPlanner.getNextExecution(stages)) {
110+
execution.validate();
111+
112+
if (execution.isExecutionRequired()) {
113+
int pendingCount = countPendingTasks(execution);
114+
throw new PendingChangesException(pendingCount);
115+
} else {
116+
break;
117+
}
118+
} catch (LockException exception) {
119+
eventPublisher.publish(new PipelineFailedEvent(exception));
120+
if (throwExceptionIfCannotObtainLock) {
121+
logger.debug("Required process lock not acquired - ABORTING VALIDATION", exception);
122+
throw exception;
123+
} else {
124+
logger.warn("Process lock not acquired but throwExceptionIfCannotObtainLock=false - CONTINUING WITHOUT LOCK", exception);
125+
}
126+
break;
127+
}
128+
} while (true);
129+
130+
resultBuilder.stopTimer().noChanges();
131+
ExecuteResponseData result = resultBuilder.build();
132+
133+
logger.info("Flamingock validation completed — no pending changes detected");
134+
eventPublisher.publish(new PipelineCompletedEvent());
135+
136+
return result;
137+
}
138+
139+
private static int countPendingTasks(ExecutionPlan execution) {
140+
int count = 0;
141+
for (ExecutableStage stage : execution.getPipeline().getExecutableStages()) {
142+
for (ExecutableTask task : stage.getTasks()) {
143+
if (!task.isAlreadyApplied()) {
144+
count++;
145+
}
146+
}
147+
}
148+
return count;
149+
}
150+
}

0 commit comments

Comments
 (0)