-
Notifications
You must be signed in to change notification settings - Fork 235
feat: expectation pattern support #2941
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
1caba0b
2a72ea1
b567e03
01dc7da
43cab4f
a7fdb91
7b8e100
6b12886
5c5a3c0
246035c
2fcaf66
603ac9d
ab91daf
c16dabf
46819d9
6134683
57a8ecc
4149721
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -258,4 +258,63 @@ In this mode: | |
| - you cannot use managed dependent resources since those manage the finalizers and other logic related to the normal | ||
| execution mode. | ||
|
|
||
| See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources; | ||
| See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources; | ||
|
|
||
| ### Expectations | ||
|
|
||
| Expectations are a pattern to ensure that, during reconciliation, your secondary resources are in a certain state. | ||
| For a more detailed explanation see [this blogpost](https://ahmet.im/blog/controller-pitfalls/#expectations-pattern). | ||
| You can find framework support for this pattern in [`io.javaoperatorsdk.operator.processing.expectation`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/) | ||
| package. See also related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java). | ||
| Note that this feature is marked as `@Experimental`, since based on feedback the API might be improved / changed, but we intend | ||
| to support it, later also might be integrated to Dependent Resources and/or Workflows. | ||
|
|
||
| The idea is the nutshell, is that you can track your expectations in the expectation manager in the reconciler | ||
| which has an API that covers the common use cases. | ||
|
|
||
| The following sample is the simplified version of the integration test that implements the logic that creates a | ||
| deployment and sets status message if there are the target three replicas ready: | ||
|
|
||
| ```java | ||
| public class ExpectationReconciler implements Reconciler<ExpectationCustomResource> { | ||
|
|
||
| // some code is omitted | ||
|
|
||
| private final ExpectationManager<ExpectationCustomResource> expectationManager = | ||
| new ExpectationManager<>(); | ||
|
|
||
| @Override | ||
| public UpdateControl<ExpectationCustomResource> reconcile( | ||
| ExpectationCustomResource primary, Context<ExpectationCustomResource> context) { | ||
|
|
||
| // exiting asap if there is an expectation that is not timed out neither fulfilled yet | ||
| if (expectationManager.ongoingExpectationPresent(primary, context)) { | ||
| return UpdateControl.noUpdate(); | ||
| } | ||
|
|
||
| var deployment = context.getSecondaryResource(Deployment.class); | ||
| if (deployment.isEmpty()) { | ||
| createDeployment(primary, context); | ||
| expectationManager.setExpectation( | ||
| primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); | ||
| return UpdateControl.noUpdate(); | ||
| } else { | ||
| // checks if the expectation if it is fulfilled, and also removes it. | ||
| // in your logic you might add a next expectation based on your workflow. | ||
| // Expectations have a name, so you can easily distinguish them if there is more of them. | ||
| var res = expectationManager.checkExpectation("deploymentReadyExpectation",primary, context); | ||
| if (res.isFulfilled()) { | ||
| return pathchStatusWithMessage(primary, DEPLOYMENT_READY); | ||
| } else if (res.isTimedOut()) { | ||
| // you might add some other timeout handling here | ||
| return pathchStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); | ||
| } | ||
| } | ||
| return UpdateControl.noUpdate(); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. delete this empty line |
||
| } | ||
| } | ||
| ``` | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /* | ||
| * Copyright Java Operator SDK Authors | ||
| * | ||
| * 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.javaoperatorsdk.operator.processing.expectation; | ||
|
|
||
| import java.util.function.BiPredicate; | ||
|
|
||
| import io.fabric8.kubernetes.api.model.HasMetadata; | ||
| import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
| import io.javaoperatorsdk.operator.api.reconciler.Experimental; | ||
|
|
||
| import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; | ||
|
|
||
| @Experimental(API_MIGHT_CHANGE) | ||
| public interface Expectation<P extends HasMetadata> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Javadoc only when finalized? I think some small context would be good also for experimental features. |
||
|
|
||
| String UNNAMED = "unnamed"; | ||
|
|
||
| boolean isFulfilled(P primary, Context<P> context); | ||
|
|
||
| default String name() { | ||
| return UNNAMED; | ||
| } | ||
|
|
||
| static <P extends HasMetadata> Expectation<P> createExpectation( | ||
| String name, BiPredicate<P, Context<P>> predicate) { | ||
| return new Expectation<>() { | ||
| @Override | ||
| public String name() { | ||
| return name; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isFulfilled(P primary, Context<P> context) { | ||
| return predicate.test(primary, context); | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||
| /* | ||||||
| * Copyright Java Operator SDK Authors | ||||||
| * | ||||||
| * 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.javaoperatorsdk.operator.processing.expectation; | ||||||
|
|
||||||
| import java.time.Duration; | ||||||
| import java.time.LocalDateTime; | ||||||
| import java.util.Optional; | ||||||
| import java.util.concurrent.ConcurrentHashMap; | ||||||
|
|
||||||
| import io.fabric8.kubernetes.api.model.HasMetadata; | ||||||
| import io.javaoperatorsdk.operator.api.reconciler.Context; | ||||||
| import io.javaoperatorsdk.operator.api.reconciler.Experimental; | ||||||
| import io.javaoperatorsdk.operator.processing.event.ResourceID; | ||||||
|
|
||||||
| import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; | ||||||
|
|
||||||
| @Experimental(API_MIGHT_CHANGE) | ||||||
| public class ExpectationManager<P extends HasMetadata> { | ||||||
|
|
||||||
| protected final ConcurrentHashMap<ResourceID, RegisteredExpectation<P>> registeredExpectations = | ||||||
| new ConcurrentHashMap<>(); | ||||||
|
|
||||||
| /** | ||||||
| * Checks if the expectation holds, if not sets the expectation with the given timeout. | ||||||
| * | ||||||
| * @return false, if the expectation is already fulfilled, therefore, not registered. Returns true | ||||||
| * if expectation is not met and set with a timeout. | ||||||
| */ | ||||||
| public boolean checkAndSetExpectation( | ||||||
| P primary, Context<P> context, Duration timeout, Expectation<P> expectation) { | ||||||
| var fulfilled = expectation.isFulfilled(primary, context); | ||||||
| if (fulfilled) { | ||||||
| return false; | ||||||
| } else { | ||||||
| setExpectation(primary, timeout, expectation); | ||||||
| return true; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Sets a target expectation with given timeout. | ||||||
| * | ||||||
| * @param primary resource | ||||||
| * @param timeout of expectation | ||||||
| * @param expectation to check | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| */ | ||||||
| // we might consider in the future to throw an exception if an expectation is already set | ||||||
| public void setExpectation(P primary, Duration timeout, Expectation<P> expectation) { | ||||||
| registeredExpectations.put( | ||||||
| ResourceID.fromResource(primary), | ||||||
| new RegisteredExpectation<>(LocalDateTime.now(), timeout, expectation)); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Checks on expectation with provided name. Return the expectation result. If the result of | ||||||
| * expectation is fulfilled, the expectation is automatically removed; | ||||||
| */ | ||||||
| public ExpectationResult<P> checkExpectation( | ||||||
| String expectationName, P primary, Context<P> context) { | ||||||
| var resourceID = ResourceID.fromResource(primary); | ||||||
| var exp = registeredExpectations.get(ResourceID.fromResource(primary)); | ||||||
| if (exp != null && expectationName.equals(exp.expectation().name())) { | ||||||
| return checkExpectation(exp, resourceID, primary, context); | ||||||
| } else { | ||||||
| return checkExpectation(null, resourceID, primary, context); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Checks if actual expectation is fulfilled. Return the expectation result. If the result of | ||||||
| * expectation is fulfilled, the expectation is automatically removed; | ||||||
| */ | ||||||
| public ExpectationResult<P> checkExpectation(P primary, Context<P> context) { | ||||||
| var resourceID = ResourceID.fromResource(primary); | ||||||
| var exp = registeredExpectations.get(ResourceID.fromResource(primary)); | ||||||
| return checkExpectation(exp, resourceID, primary, context); | ||||||
| } | ||||||
|
|
||||||
| private ExpectationResult<P> checkExpectation( | ||||||
| RegisteredExpectation<P> exp, ResourceID resourceID, P primary, Context<P> context) { | ||||||
| if (exp == null) { | ||||||
| return new ExpectationResult<>(null, null); | ||||||
| } | ||||||
| if (exp.expectation().isFulfilled(primary, context)) { | ||||||
| registeredExpectations.remove(resourceID); | ||||||
| return new ExpectationResult<>(exp.expectation(), ExpectationStatus.FULFILLED); | ||||||
| } else if (exp.isTimedOut()) { | ||||||
| // we don't remove the expectation so user knows about it's state | ||||||
| return new ExpectationResult<>(exp.expectation(), ExpectationStatus.TIMED_OUT); | ||||||
| } else { | ||||||
| return new ExpectationResult<>(exp.expectation(), ExpectationStatus.NOT_YET_FULFILLED); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /* | ||||||
| * Returns true if there is an expectation for the primary resource, but it is not yet fulfilled | ||||||
| * neither timed out. | ||||||
| * The intention behind is that you can exit reconciliation early with a simple check | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| * if true. | ||||||
| * */ | ||||||
| public boolean ongoingExpectationPresent(P primary, Context<P> context) { | ||||||
| var exp = registeredExpectations.get(ResourceID.fromResource(primary)); | ||||||
| if (exp == null) { | ||||||
| return false; | ||||||
| } | ||||||
| return !exp.isTimedOut() && !exp.expectation().isFulfilled(primary, context); | ||||||
| } | ||||||
|
|
||||||
| public boolean isExpectationPresent(P primary) { | ||||||
| return registeredExpectations.containsKey(ResourceID.fromResource(primary)); | ||||||
| } | ||||||
|
|
||||||
| public boolean isExpectationPresent(String name, P primary) { | ||||||
| var exp = registeredExpectations.get(ResourceID.fromResource(primary)); | ||||||
| return exp != null && name.equals(exp.expectation().name()); | ||||||
| } | ||||||
|
|
||||||
| public Optional<Expectation<P>> getExpectation(P primary) { | ||||||
| var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); | ||||||
| return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); | ||||||
| } | ||||||
|
|
||||||
| public Optional<String> getExpectationName(P primary) { | ||||||
| return getExpectation(primary).map(Expectation::name); | ||||||
| } | ||||||
|
|
||||||
| public void removeExpectation(P primary) { | ||||||
| registeredExpectations.remove(ResourceID.fromResource(primary)); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /* | ||
| * Copyright Java Operator SDK Authors | ||
| * | ||
| * 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.javaoperatorsdk.operator.processing.expectation; | ||
|
|
||
| import io.fabric8.kubernetes.api.model.HasMetadata; | ||
|
|
||
| public record ExpectationResult<P extends HasMetadata>( | ||
| Expectation<P> expectation, ExpectationStatus status) { | ||
|
|
||
| public boolean isFulfilled() { | ||
| return status == ExpectationStatus.FULFILLED; | ||
| } | ||
|
|
||
| public boolean isTimedOut() { | ||
| return status == ExpectationStatus.TIMED_OUT; | ||
| } | ||
|
|
||
| public boolean isExpectationPresent() { | ||
| return expectation != null; | ||
| } | ||
|
|
||
| public boolean isNotPresentOrFulfilled() { | ||
| return !isExpectationPresent() || isFulfilled(); | ||
| } | ||
|
|
||
| public String name() { | ||
| return expectation.name(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /* | ||
| * Copyright Java Operator SDK Authors | ||
| * | ||
| * 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.javaoperatorsdk.operator.processing.expectation; | ||
|
|
||
| public enum ExpectationStatus { | ||
| FULFILLED, | ||
| NOT_YET_FULFILLED, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest |
||
| TIMED_OUT | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.