+ 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();
+ }
+}
diff --git a/pom.xml b/pom.xml
index 57431dde1c..ae2bb24ae7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
pom
Operator SDK for Java
Java SDK for implementing Kubernetes operators
diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml
index 3453174aad..af4be01972 100644
--- a/sample-operators/controller-namespace-deletion/pom.xml
+++ b/sample-operators/controller-namespace-deletion/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-controller-namespace-deletion
diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml
index 42fd41b3f3..4f896485d1 100644
--- a/sample-operators/leader-election/pom.xml
+++ b/sample-operators/leader-election/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-leader-election
diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml
index 6e62ebfde8..d2872c921a 100644
--- a/sample-operators/mysql-schema/pom.xml
+++ b/sample-operators/mysql-schema/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-mysql-schema-operator
diff --git a/sample-operators/operations/pom.xml b/sample-operators/operations/pom.xml
index 7940f54aaa..1786cf39d0 100644
--- a/sample-operators/operations/pom.xml
+++ b/sample-operators/operations/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-operations
diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml
index 2236f39543..9313095584 100644
--- a/sample-operators/pom.xml
+++ b/sample-operators/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-operators
diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml
index bf096330f6..ea964a2b07 100644
--- a/sample-operators/tomcat-operator/pom.xml
+++ b/sample-operators/tomcat-operator/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-tomcat-operator
diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml
index 707fd33d8d..d50e5ef03c 100644
--- a/sample-operators/webpage/pom.xml
+++ b/sample-operators/webpage/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
sample-operators
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
sample-webpage-operator
diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml
index c2c8e380e8..2ae7c5f454 100644
--- a/test-index-processor/pom.xml
+++ b/test-index-processor/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.4.1-SNAPSHOT
+ 999-SNAPSHOT
test-index-processor