diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java new file mode 100644 index 0000000000..89c0c993e4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.baseapi.externalstateinstatus; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("essis") +public class ExternalStateInStatusCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.java new file mode 100644 index 0000000000..56e7e3aed9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.java @@ -0,0 +1,106 @@ +/* + * 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.baseapi.externalstateinstatus; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalServiceResetExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Manages an external resource while storing its state (the external resource ID) directly in the + * status of the custom resource, rather than in a separate resource like a ConfigMap. This + * is only reliable because of the stronger read-after-write consistency for updates: after the + * external resource is created the reconciler patches the status with the ID, and that patched + * resource is placed into the cache so the next reconciliation observes the ID and does not create + * a duplicate external resource. + */ +@Sample( + tldr = "Managing an External Resource with State Stored in the Status", + description = + """ + Demonstrates how to manage an external resource (outside of Kubernetes) while storing its \ + state - the generated external ID - in the status of the custom resource. The reconciler \ + persists the ID with a status patch and relies on the stronger read-after-write \ + consistency for updates so that the next reconciliation observes the stored ID and never \ + creates a duplicate external resource. A fake external service stands in for the managed \ + external system. + """) +@ExtendWith(ExternalServiceResetExtension.class) +class ExternalStateInStatusIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateInStatusReconciler.class) + .build(); + + @Test + void reconcilesResourceWithStateStoredInStatus() { + var resource = operator.create(testResource()); + assertResourceCreated(INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + assertResourceCreated(UPDATED_DATA); + + operator.delete(resource); + assertResourceDeleted(); + } + + private void assertResourceCreated(String expectedData) { + await() + .untilAsserted( + () -> { + var resources = externalService.listResources(); + // exactly one external resource is created, no duplicates + assertThat(resources).hasSize(1); + var extRes = resources.get(0); + assertThat(extRes.getData()).isEqualTo(expectedData); + + var cr = operator.get(ExternalStateInStatusCustomResource.class, TEST_RESOURCE_NAME); + assertThat(cr.getStatus()).isNotNull(); + // the external resource state (its ID) is stored in the status + assertThat(cr.getStatus().getId()).isEqualTo(extRes.getId()); + }); + } + + private void assertResourceDeleted() { + await().untilAsserted(() -> assertThat(externalService.listResources()).isEmpty()); + } + + private ExternalStateInStatusCustomResource testResource() { + var res = new ExternalStateInStatusCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new ExternalStateInStatusSpec().setData(INITIAL_TEST_DATA)); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java new file mode 100644 index 0000000000..e6b567236f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java @@ -0,0 +1,156 @@ +/* + * 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.baseapi.externalstateinstatus; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * Manages an external resource (living in the {@link ExternalIDGenServiceMock fake external + * service}) while storing the external resource's state - here just its generated ID - directly in + * the status of the custom resource. + * + *

This pattern only works reliably thanks to the stronger read-after-write consistency for + * updates: after the external resource is created, its ID is persisted through {@link + * UpdateControl#patchStatus(io.fabric8.kubernetes.api.model.HasMetadata)}. The patched resource + * (holding the ID) is placed into the controller's cache, so the very next reconciliation observes + * the ID and does not create a duplicate external resource - even before the informer delivers the + * update event. + */ +@ControllerConfiguration +public class ExternalStateInStatusReconciler + implements Reconciler, + Cleaner, + TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + PerResourcePollingEventSource + externalResourceEventSource; + + @Override + public UpdateControl reconcile( + ExternalStateInStatusCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + var externalResource = context.getSecondaryResource(ExternalResource.class); + if (externalResource.isEmpty()) { + // No external resource is associated with this primary yet. If we already stored an ID we + // are just waiting for the poll to catch up (do nothing), otherwise we create the external + // resource and persist its ID into the status. Relying on read-after-write consistency the + // stored ID is visible on the next reconciliation, so no duplicate is created. + if (idFromStatus(resource) == null) { + return createExternalResource(resource); + } + return UpdateControl.noUpdate(); + } + + var currentExternalResource = externalResource.orElseThrow(); + if (!currentExternalResource.getData().equals(resource.getSpec().getData())) { + updateExternalResource(resource, currentExternalResource); + } + return UpdateControl.noUpdate(); + } + + private UpdateControl createExternalResource( + ExternalStateInStatusCustomResource resource) { + var createdResource = + externalService.create(new ExternalResource(resource.getSpec().getData())); + + // Make sure the freshly created external resource is available in the poll cache for the next + // reconciliation, so it is not created again. + externalResourceEventSource.handleRecentResourceCreate( + ResourceID.fromResource(resource), createdResource); + + resource.setStatus(new ExternalStateInStatusStatus().setId(createdResource.getId())); + return UpdateControl.patchStatus(resource); + } + + private void updateExternalResource( + ExternalStateInStatusCustomResource resource, ExternalResource externalResource) { + var newResource = new ExternalResource(externalResource.getId(), resource.getSpec().getData()); + externalService.update(newResource); + externalResourceEventSource.handleRecentResourceUpdate( + ResourceID.fromResource(resource), newResource, externalResource); + } + + @Override + public DeleteControl cleanup( + ExternalStateInStatusCustomResource resource, + Context context) { + var id = idFromStatus(resource); + if (id != null) { + externalService.delete(id); + } + return DeleteControl.defaultDelete(); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + final PerResourcePollingEventSource.ResourceFetcher< + ExternalResource, ExternalStateInStatusCustomResource> + fetcher = + (ExternalStateInStatusCustomResource primaryResource) -> { + var id = idFromStatus(primaryResource); + if (id == null) { + return Collections.emptySet(); + } + return externalService.read(id).map(Set::of).orElseGet(Collections::emptySet); + }; + externalResourceEventSource = + new PerResourcePollingEventSource<>( + ExternalResource.class, + context, + new PerResourcePollingConfigurationBuilder< + ExternalResource, ExternalStateInStatusCustomResource, String>( + fetcher, Duration.ofMillis(300L)) + .build()); + + return List.of(externalResourceEventSource); + } + + private static String idFromStatus(ExternalStateInStatusCustomResource resource) { + return resource.getStatus() == null ? null : resource.getStatus().getId(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java new file mode 100644 index 0000000000..1eecb6959c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.externalstateinstatus; + +public class ExternalStateInStatusSpec { + + private String data; + + public String getData() { + return data; + } + + public ExternalStateInStatusSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java new file mode 100644 index 0000000000..f947448760 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java @@ -0,0 +1,31 @@ +/* + * 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.baseapi.externalstateinstatus; + +/** Holds the identifier of the managed external resource. This is the external resource state. */ +public class ExternalStateInStatusStatus { + + private String id; + + public String getId() { + return id; + } + + public ExternalStateInStatusStatus setId(String id) { + this.id = id; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java new file mode 100644 index 0000000000..bdfad5271f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java @@ -0,0 +1,115 @@ +/* + * 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.dependent.externalstateinstatus; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.external.PerResourcePollingDependentResource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; + +/** + * Dependent resource managing an external resource in the {@link ExternalIDGenServiceMock fake + * external service}. The external resource's state - its generated ID - is read from the status + * of the primary custom resource. The ID is written into the status by {@link + * ExternalStateInStatusWorkflowReconciler} after the workflow reconciled this dependent, relying on + * the stronger read-after-write consistency for updates so that the next fetch/reconciliation + * observes the ID and does not create a duplicate external resource. + */ +public class ExternalStateInStatusDependentResource + extends PerResourcePollingDependentResource< + ExternalResource, ExternalStateInStatusWorkflowCustomResource, String> + implements Creator, + Updater { + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + public ExternalStateInStatusDependentResource() { + super(ExternalResource.class, Duration.ofMillis(300)); + } + + @Override + public Set fetchResources( + ExternalStateInStatusWorkflowCustomResource primaryResource) { + return idFromStatus(primaryResource) + .flatMap(externalService::read) + .map(Set::of) + .orElseGet(Collections::emptySet); + } + + @Override + protected Optional selectTargetSecondaryResource( + Set secondaryResources, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return idFromStatus(primary) + .flatMap(id -> secondaryResources.stream().filter(e -> e.getId().equals(id)).findAny()); + } + + @Override + protected ExternalResource desired( + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return new ExternalResource(primary.getSpec().getData()); + } + + @Override + public ExternalResource create( + ExternalResource desired, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return externalService.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return externalService.update(new ExternalResource(actual.getId(), desired.getData())); + } + + @Override + public Matcher.Result match( + ExternalResource resource, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return Matcher.Result.nonComputed(resource.getData().equals(primary.getSpec().getData())); + } + + @Override + protected void handleDelete( + ExternalStateInStatusWorkflowCustomResource primary, + ExternalResource secondary, + Context context) { + if (secondary != null) { + externalService.delete(secondary.getId()); + } + } + + private static Optional idFromStatus( + ExternalStateInStatusWorkflowCustomResource primary) { + return Optional.ofNullable(primary.getStatus()).map(ExternalStateInStatusStatus::getId); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java new file mode 100644 index 0000000000..7bd6cabc0f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java @@ -0,0 +1,30 @@ +/* + * 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.dependent.externalstateinstatus; + +public class ExternalStateInStatusSpec { + + private String data; + + public String getData() { + return data; + } + + public ExternalStateInStatusSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java new file mode 100644 index 0000000000..8d331b5ad3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java @@ -0,0 +1,31 @@ +/* + * 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.dependent.externalstateinstatus; + +/** Holds the identifier of the managed external resource. This is the external resource state. */ +public class ExternalStateInStatusStatus { + + private String id; + + public String getId() { + return id; + } + + public ExternalStateInStatusStatus setId(String id) { + this.id = id; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java new file mode 100644 index 0000000000..252616a693 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.dependent.externalstateinstatus; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("essisw") +public class ExternalStateInStatusWorkflowCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java new file mode 100644 index 0000000000..7489ee3a69 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java @@ -0,0 +1,107 @@ +/* + * 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.dependent.externalstateinstatus; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalServiceResetExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Manages an external resource through a managed workflow with a {@link + * ExternalStateInStatusDependentResource dependent resource}, while storing the external resource + * state (its ID) in the status of the custom resource. This only works reliably because of the + * stronger read-after-write consistency for updates. + */ +@Sample( + tldr = "Managing an External Resource via a Workflow with State Stored in the Status", + description = + """ + Demonstrates managing an external resource (outside of Kubernetes) with a managed workflow \ + and a dependent resource, while storing its state - the generated external ID - in the \ + status of the custom resource. The reconciler persists the ID with a status patch and \ + relies on the stronger read-after-write consistency for updates so that the next \ + reconciliation and the dependent's fetch observe the stored ID and never create a \ + duplicate external resource. A fake external service stands in for the managed external \ + system. + """) +@ExtendWith(ExternalServiceResetExtension.class) +class ExternalStateInStatusWorkflowIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateInStatusWorkflowReconciler.class) + .build(); + + @Test + void reconcilesExternalResourceWithWorkflowStoringStateInStatus() { + var resource = operator.create(testResource()); + assertResourceCreated(INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + assertResourceCreated(UPDATED_DATA); + + operator.delete(resource); + assertResourceDeleted(); + } + + private void assertResourceCreated(String expectedData) { + await() + .untilAsserted( + () -> { + var resources = externalService.listResources(); + // exactly one external resource is created, no duplicates + assertThat(resources).hasSize(1); + var extRes = resources.get(0); + assertThat(extRes.getData()).isEqualTo(expectedData); + + var cr = + operator.get( + ExternalStateInStatusWorkflowCustomResource.class, TEST_RESOURCE_NAME); + assertThat(cr.getStatus()).isNotNull(); + // the external resource state (its ID) is stored in the status + assertThat(cr.getStatus().getId()).isEqualTo(extRes.getId()); + }); + } + + private void assertResourceDeleted() { + await().untilAsserted(() -> assertThat(externalService.listResources()).isEmpty()); + } + + private ExternalStateInStatusWorkflowCustomResource testResource() { + var res = new ExternalStateInStatusWorkflowCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new ExternalStateInStatusSpec().setData(INITIAL_TEST_DATA)); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java new file mode 100644 index 0000000000..26687e0ba0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java @@ -0,0 +1,71 @@ +/* + * 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.dependent.externalstateinstatus; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * Manages an external resource through a managed workflow with a single {@link + * ExternalStateInStatusDependentResource dependent resource}, while storing the external resource's + * state - its generated ID - in the status of the custom resource. + * + *

The managed workflow reconciles the dependent (creating the external resource) just before + * this {@code reconcile} method runs. The reconciler then persists the external ID into the status + * with {@link UpdateControl#patchStatus(io.fabric8.kubernetes.api.model.HasMetadata)}. Thanks to + * the stronger read-after-write consistency for updates, the patched status is placed into the + * cache, so the next reconciliation - and the dependent's fetch - observe the ID and do not create + * a duplicate external resource. + */ +@Workflow(dependents = @Dependent(type = ExternalStateInStatusDependentResource.class)) +@ControllerConfiguration +public class ExternalStateInStatusWorkflowReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ExternalStateInStatusWorkflowCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + var externalResource = context.getSecondaryResource(ExternalResource.class); + if (externalResource.isEmpty()) { + return UpdateControl.noUpdate(); + } + + var id = externalResource.orElseThrow().getId(); + if (resource.getStatus() == null || !id.equals(resource.getStatus().getId())) { + resource.setStatus(new ExternalStateInStatusStatus().setId(id)); + return UpdateControl.patchStatus(resource); + } + return UpdateControl.noUpdate(); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +}