Skip to content

Commit efb549a

Browse files
committed
test: cocurrent non changing resource reproducer
Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent 0a81713 commit efb549a

5 files changed

Lines changed: 268 additions & 0 deletions

File tree

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.readcacheafterwrite.specchangeduringstatuspatch;
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("scdsp")
27+
public class SpecChangeDuringStatusPatchCustomResource
28+
extends CustomResource<SpecChangeDuringStatusPatchSpec, SpecChangeDuringStatusPatchStatus>
29+
implements Namespaced {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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.readcacheafterwrite.specchangeduringstatuspatch;
17+
18+
import java.time.Duration;
19+
import java.util.concurrent.TimeUnit;
20+
21+
import org.junit.jupiter.api.RepeatedTest;
22+
import org.junit.jupiter.api.extension.RegisterExtension;
23+
24+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
25+
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
26+
import io.fabric8.kubernetes.client.dsl.base.PatchType;
27+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
28+
29+
import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.specchangeduringstatuspatch.SpecChangeDuringStatusPatchReconciler.STATUS_VALUE;
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.awaitility.Awaitility.await;
32+
33+
/**
34+
* Reproduces a concurrent spec change happening while the controller patches its own status. When
35+
* the reconciler patches the status it opens an event filtering window so it does not re-trigger
36+
* itself. This test changes the spec on the cluster while that window is open and verifies that the
37+
* spec change is still reconciled - it must not be silently absorbed together with the controller's
38+
* own status update.
39+
*/
40+
class SpecChangeDuringStatusPatchIT {
41+
42+
static final String RESOURCE_NAME = "test-resource";
43+
static final String SPEC_VALUE = "initial";
44+
public static final String UPDATED_SPEC_VALUE = "updated-val";
45+
46+
SpecChangeDuringStatusPatchReconciler reconciler = new SpecChangeDuringStatusPatchReconciler();
47+
48+
@RegisterExtension
49+
LocallyRunOperatorExtension extension =
50+
LocallyRunOperatorExtension.builder().withReconciler(reconciler).build();
51+
52+
@RepeatedTest(10)
53+
void specChangeDuringStatusPatchIsReconciled() throws InterruptedException {
54+
var res = extension.create(testResource());
55+
var statusRes = testResource();
56+
statusRes.getMetadata().setNamespace(res.getMetadata().getNamespace());
57+
extension
58+
.getKubernetesClient()
59+
.resource(statusRes)
60+
.status()
61+
.patch(
62+
new PatchContext.Builder()
63+
.withForce(true)
64+
.withFieldManager(
65+
SpecChangeDuringStatusPatchReconciler.class.getSimpleName().toLowerCase())
66+
.withPatchType(PatchType.SERVER_SIDE_APPLY)
67+
.build());
68+
69+
// wait until the reconciler is inside its own status patch, holding the filtering window open
70+
assertThat(reconciler.statusPatchStartedLatch.await(30, TimeUnit.SECONDS))
71+
.as("reconciler should enter its own status patch operation")
72+
.isTrue();
73+
74+
// change the spec on the cluster while the controller's status patch is still in flight
75+
var current = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME);
76+
current.getSpec().setValue(UPDATED_SPEC_VALUE);
77+
extension.replace(current);
78+
79+
// let the reconciler finish its own status patch
80+
reconciler.specChangeDoneLatch.countDown();
81+
82+
// the spec change must be picked up by a fresh reconciliation and not lost with the own update
83+
await()
84+
.atMost(Duration.ofSeconds(5))
85+
.untilAsserted(
86+
() -> {
87+
assertThat(reconciler.numberOfExecutions.get()).isGreaterThanOrEqualTo(2);
88+
assertThat(reconciler.lastObservedSpecValue.get())
89+
.as("a later reconciliation must observe the externally-applied spec change")
90+
.isEqualTo(UPDATED_SPEC_VALUE);
91+
});
92+
93+
// sanity check: the status the controller set is still present after the concurrent spec change
94+
var updated = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME);
95+
assertThat(updated.getSpec().getValue()).isEqualTo(UPDATED_SPEC_VALUE);
96+
assertThat(updated.getStatus()).isNotNull();
97+
assertThat(updated.getStatus().getValue()).isEqualTo(STATUS_VALUE);
98+
}
99+
100+
SpecChangeDuringStatusPatchCustomResource testResource() {
101+
var r = new SpecChangeDuringStatusPatchCustomResource();
102+
r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build());
103+
r.setSpec(new SpecChangeDuringStatusPatchSpec().setValue(SPEC_VALUE));
104+
r.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE));
105+
return r;
106+
}
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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.readcacheafterwrite.specchangeduringstatuspatch;
17+
18+
import java.util.concurrent.CountDownLatch;
19+
import java.util.concurrent.TimeUnit;
20+
import java.util.concurrent.atomic.AtomicInteger;
21+
import java.util.concurrent.atomic.AtomicReference;
22+
23+
import io.javaoperatorsdk.operator.api.reconciler.Context;
24+
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
25+
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
26+
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
27+
28+
/**
29+
* On the first reconciliation the reconciler patches its own status, but keeps the event filtering
30+
* window (opened by {@link
31+
* io.javaoperatorsdk.operator.api.reconciler.ResourceOperations#resourcePatch}) open until the test
32+
* signals that it has changed the spec on the cluster. This reproduces the race where a spec change
33+
* lands while the controller's own status patch is in flight: the spec change event must still
34+
* propagate as a fresh reconciliation, it must not be absorbed as if it were our own status update.
35+
*/
36+
@ControllerConfiguration
37+
public class SpecChangeDuringStatusPatchReconciler
38+
implements Reconciler<SpecChangeDuringStatusPatchCustomResource> {
39+
40+
static final String STATUS_VALUE = "reconciled";
41+
42+
final AtomicInteger numberOfExecutions = new AtomicInteger();
43+
final CountDownLatch statusPatchStartedLatch = new CountDownLatch(1);
44+
final CountDownLatch specChangeDoneLatch = new CountDownLatch(1);
45+
final AtomicReference<String> lastObservedSpecValue = new AtomicReference<>();
46+
47+
@Override
48+
public UpdateControl<SpecChangeDuringStatusPatchCustomResource> reconcile(
49+
SpecChangeDuringStatusPatchCustomResource resource,
50+
Context<SpecChangeDuringStatusPatchCustomResource> context) {
51+
int execution = numberOfExecutions.incrementAndGet();
52+
lastObservedSpecValue.set(resource.getSpec().getValue());
53+
54+
if (execution == 1) {
55+
resource.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE));
56+
resource.getMetadata().setResourceVersion(null);
57+
// Patch our own status, but hold the filtering window open with a hook that lets the test
58+
// change the spec on the cluster WHILE the status patch is still in flight.
59+
statusPatchStartedLatch.countDown();
60+
try {
61+
if (!specChangeDoneLatch.await(30, TimeUnit.SECONDS)) {
62+
throw new IllegalStateException("timed out waiting for external spec change");
63+
}
64+
} catch (InterruptedException e) {
65+
Thread.currentThread().interrupt();
66+
throw new IllegalStateException(e);
67+
}
68+
return UpdateControl.patchStatus(resource);
69+
}
70+
return UpdateControl.noUpdate();
71+
}
72+
}
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.readcacheafterwrite.specchangeduringstatuspatch;
17+
18+
public class SpecChangeDuringStatusPatchSpec {
19+
20+
private String value;
21+
22+
public String getValue() {
23+
return value;
24+
}
25+
26+
public SpecChangeDuringStatusPatchSpec setValue(String value) {
27+
this.value = value;
28+
return this;
29+
}
30+
}
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.readcacheafterwrite.specchangeduringstatuspatch;
17+
18+
public class SpecChangeDuringStatusPatchStatus {
19+
20+
private String value;
21+
22+
public String getValue() {
23+
return value;
24+
}
25+
26+
public SpecChangeDuringStatusPatchStatus setValue(String value) {
27+
this.value = value;
28+
return this;
29+
}
30+
}

0 commit comments

Comments
 (0)