Skip to content

Commit c3077df

Browse files
committed
improve: add integration tests showcasing the usage of id from primary status
Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent e1b4e92 commit c3077df

11 files changed

Lines changed: 735 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
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.javaoperatorsdk.operator.baseapi.externalstateinstatus;
17+
18+
import io.fabric8.kubernetes.api.model.Namespaced;
19+
import io.fabric8.kubernetes.client.CustomResource;
20+
import io.fabric8.kubernetes.model.annotation.Group;
21+
import io.fabric8.kubernetes.model.annotation.ShortNames;
22+
import io.fabric8.kubernetes.model.annotation.Version;
23+
24+
@Group("sample.javaoperatorsdk")
25+
@Version("v1")
26+
@ShortNames("essis")
27+
public class ExternalStateInStatusCustomResource
28+
extends CustomResource<ExternalStateInStatusSpec, ExternalStateInStatusStatus>
29+
implements Namespaced {}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
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.javaoperatorsdk.operator.baseapi.externalstateinstatus;
17+
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.extension.ExtendWith;
20+
import org.junit.jupiter.api.extension.RegisterExtension;
21+
22+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
23+
import io.javaoperatorsdk.annotation.Sample;
24+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
25+
import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock;
26+
import io.javaoperatorsdk.operator.support.ExternalServiceResetExtension;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
import static org.awaitility.Awaitility.await;
30+
31+
/**
32+
* Manages an external resource while storing its state (the external resource ID) directly in the
33+
* <b>status</b> of the custom resource, rather than in a separate resource like a ConfigMap. This
34+
* is only reliable because of the stronger read-after-write consistency for updates: after the
35+
* external resource is created the reconciler patches the status with the ID, and that patched
36+
* resource is placed into the cache so the next reconciliation observes the ID and does not create
37+
* a duplicate external resource.
38+
*/
39+
@Sample(
40+
tldr = "Managing an External Resource with State Stored in the Status",
41+
description =
42+
"""
43+
Demonstrates how to manage an external resource (outside of Kubernetes) while storing its \
44+
state - the generated external ID - in the status of the custom resource. The reconciler \
45+
persists the ID with a status patch and relies on the stronger read-after-write \
46+
consistency for updates so that the next reconciliation observes the stored ID and never \
47+
creates a duplicate external resource. A fake external service stands in for the managed \
48+
external system.
49+
""")
50+
@ExtendWith(ExternalServiceResetExtension.class)
51+
class ExternalStateInStatusIT {
52+
53+
private static final String TEST_RESOURCE_NAME = "test1";
54+
55+
public static final String INITIAL_TEST_DATA = "initialTestData";
56+
public static final String UPDATED_DATA = "updatedData";
57+
58+
private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance();
59+
60+
@RegisterExtension
61+
LocallyRunOperatorExtension operator =
62+
LocallyRunOperatorExtension.builder()
63+
.withReconciler(ExternalStateInStatusReconciler.class)
64+
.build();
65+
66+
@Test
67+
void reconcilesResourceWithStateStoredInStatus() {
68+
var resource = operator.create(testResource());
69+
assertResourceCreated(INITIAL_TEST_DATA);
70+
71+
resource.getSpec().setData(UPDATED_DATA);
72+
operator.replace(resource);
73+
assertResourceCreated(UPDATED_DATA);
74+
75+
operator.delete(resource);
76+
assertResourceDeleted();
77+
}
78+
79+
private void assertResourceCreated(String expectedData) {
80+
await()
81+
.untilAsserted(
82+
() -> {
83+
var resources = externalService.listResources();
84+
// exactly one external resource is created, no duplicates
85+
assertThat(resources).hasSize(1);
86+
var extRes = resources.get(0);
87+
assertThat(extRes.getData()).isEqualTo(expectedData);
88+
89+
var cr = operator.get(ExternalStateInStatusCustomResource.class, TEST_RESOURCE_NAME);
90+
assertThat(cr.getStatus()).isNotNull();
91+
// the external resource state (its ID) is stored in the status
92+
assertThat(cr.getStatus().getId()).isEqualTo(extRes.getId());
93+
});
94+
}
95+
96+
private void assertResourceDeleted() {
97+
await().untilAsserted(() -> assertThat(externalService.listResources()).isEmpty());
98+
}
99+
100+
private ExternalStateInStatusCustomResource testResource() {
101+
var res = new ExternalStateInStatusCustomResource();
102+
res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build());
103+
res.setSpec(new ExternalStateInStatusSpec().setData(INITIAL_TEST_DATA));
104+
return res;
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
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.javaoperatorsdk.operator.baseapi.externalstateinstatus;
17+
18+
import java.time.Duration;
19+
import java.util.Collections;
20+
import java.util.List;
21+
import java.util.Set;
22+
import java.util.concurrent.atomic.AtomicInteger;
23+
24+
import io.javaoperatorsdk.operator.api.reconciler.Cleaner;
25+
import io.javaoperatorsdk.operator.api.reconciler.Context;
26+
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
27+
import io.javaoperatorsdk.operator.api.reconciler.DeleteControl;
28+
import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
29+
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
30+
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
31+
import io.javaoperatorsdk.operator.processing.event.ResourceID;
32+
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
33+
import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder;
34+
import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource;
35+
import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock;
36+
import io.javaoperatorsdk.operator.support.ExternalResource;
37+
import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider;
38+
39+
/**
40+
* Manages an external resource (living in the {@link ExternalIDGenServiceMock fake external
41+
* service}) while storing the external resource's state - here just its generated ID - directly in
42+
* the <b>status</b> of the custom resource.
43+
*
44+
* <p>This pattern only works reliably thanks to the stronger read-after-write consistency for
45+
* updates: after the external resource is created, its ID is persisted through {@link
46+
* UpdateControl#patchStatus(io.fabric8.kubernetes.api.model.HasMetadata)}. The patched resource
47+
* (holding the ID) is placed into the controller's cache, so the very next reconciliation observes
48+
* the ID and does not create a duplicate external resource - even before the informer delivers the
49+
* update event.
50+
*/
51+
@ControllerConfiguration
52+
public class ExternalStateInStatusReconciler
53+
implements Reconciler<ExternalStateInStatusCustomResource>,
54+
Cleaner<ExternalStateInStatusCustomResource>,
55+
TestExecutionInfoProvider {
56+
57+
private final AtomicInteger numberOfExecutions = new AtomicInteger(0);
58+
59+
private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance();
60+
61+
PerResourcePollingEventSource<ExternalResource, ExternalStateInStatusCustomResource, String>
62+
externalResourceEventSource;
63+
64+
@Override
65+
public UpdateControl<ExternalStateInStatusCustomResource> reconcile(
66+
ExternalStateInStatusCustomResource resource,
67+
Context<ExternalStateInStatusCustomResource> context) {
68+
numberOfExecutions.addAndGet(1);
69+
70+
var externalResource = context.getSecondaryResource(ExternalResource.class);
71+
if (externalResource.isEmpty()) {
72+
// No external resource is associated with this primary yet. If we already stored an ID we
73+
// are just waiting for the poll to catch up (do nothing), otherwise we create the external
74+
// resource and persist its ID into the status. Relying on read-after-write consistency the
75+
// stored ID is visible on the next reconciliation, so no duplicate is created.
76+
if (idFromStatus(resource) == null) {
77+
return createExternalResource(resource);
78+
}
79+
return UpdateControl.noUpdate();
80+
}
81+
82+
var currentExternalResource = externalResource.orElseThrow();
83+
if (!currentExternalResource.getData().equals(resource.getSpec().getData())) {
84+
updateExternalResource(resource, currentExternalResource);
85+
}
86+
return UpdateControl.noUpdate();
87+
}
88+
89+
private UpdateControl<ExternalStateInStatusCustomResource> createExternalResource(
90+
ExternalStateInStatusCustomResource resource) {
91+
var createdResource =
92+
externalService.create(new ExternalResource(resource.getSpec().getData()));
93+
94+
// Make sure the freshly created external resource is available in the poll cache for the next
95+
// reconciliation, so it is not created again.
96+
externalResourceEventSource.handleRecentResourceCreate(
97+
ResourceID.fromResource(resource), createdResource);
98+
99+
resource.setStatus(new ExternalStateInStatusStatus().setId(createdResource.getId()));
100+
return UpdateControl.patchStatus(resource);
101+
}
102+
103+
private void updateExternalResource(
104+
ExternalStateInStatusCustomResource resource, ExternalResource externalResource) {
105+
var newResource = new ExternalResource(externalResource.getId(), resource.getSpec().getData());
106+
externalService.update(newResource);
107+
externalResourceEventSource.handleRecentResourceUpdate(
108+
ResourceID.fromResource(resource), newResource, externalResource);
109+
}
110+
111+
@Override
112+
public DeleteControl cleanup(
113+
ExternalStateInStatusCustomResource resource,
114+
Context<ExternalStateInStatusCustomResource> context) {
115+
var id = idFromStatus(resource);
116+
if (id != null) {
117+
externalService.delete(id);
118+
}
119+
return DeleteControl.defaultDelete();
120+
}
121+
122+
@Override
123+
public int getNumberOfExecutions() {
124+
return numberOfExecutions.get();
125+
}
126+
127+
@Override
128+
public List<EventSource<?, ExternalStateInStatusCustomResource>> prepareEventSources(
129+
EventSourceContext<ExternalStateInStatusCustomResource> context) {
130+
131+
final PerResourcePollingEventSource.ResourceFetcher<
132+
ExternalResource, ExternalStateInStatusCustomResource>
133+
fetcher =
134+
(ExternalStateInStatusCustomResource primaryResource) -> {
135+
var id = idFromStatus(primaryResource);
136+
if (id == null) {
137+
return Collections.emptySet();
138+
}
139+
return externalService.read(id).map(Set::of).orElseGet(Collections::emptySet);
140+
};
141+
externalResourceEventSource =
142+
new PerResourcePollingEventSource<>(
143+
ExternalResource.class,
144+
context,
145+
new PerResourcePollingConfigurationBuilder<
146+
ExternalResource, ExternalStateInStatusCustomResource, String>(
147+
fetcher, Duration.ofMillis(300L))
148+
.build());
149+
150+
return List.of(externalResourceEventSource);
151+
}
152+
153+
private static String idFromStatus(ExternalStateInStatusCustomResource resource) {
154+
return resource.getStatus() == null ? null : resource.getStatus().getId();
155+
}
156+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
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.javaoperatorsdk.operator.baseapi.externalstateinstatus;
17+
18+
public class ExternalStateInStatusSpec {
19+
20+
private String data;
21+
22+
public String getData() {
23+
return data;
24+
}
25+
26+
public ExternalStateInStatusSpec setData(String data) {
27+
this.data = data;
28+
return this;
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
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.javaoperatorsdk.operator.baseapi.externalstateinstatus;
17+
18+
/** Holds the identifier of the managed external resource. This is the external resource state. */
19+
public class ExternalStateInStatusStatus {
20+
21+
private String id;
22+
23+
public String getId() {
24+
return id;
25+
}
26+
27+
public ExternalStateInStatusStatus setId(String id) {
28+
this.id = id;
29+
return this;
30+
}
31+
}

0 commit comments

Comments
 (0)