-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(operator): introduce ClusterUser to make actor-vs-SUT boundary explicit #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fbcb7cc
467fefb
dd050b5
066de5e
576826a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /* | ||
| * Copyright Kroxylicious Authors. | ||
| * | ||
| * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 | ||
| */ | ||
|
|
||
| package io.kroxylicious.kubernetes.operator; | ||
|
|
||
| import io.fabric8.kubernetes.api.model.HasMetadata; | ||
| import io.fabric8.kubernetes.api.model.KubernetesResourceList; | ||
| import io.fabric8.kubernetes.client.KubernetesClient; | ||
| import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; | ||
| import io.fabric8.kubernetes.client.dsl.Resource; | ||
|
|
||
| import edu.umd.cs.findbugs.annotations.NonNull; | ||
| import edu.umd.cs.findbugs.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Represents a cluster user interacting with Kubernetes resources in a specific namespace. | ||
| * <p> | ||
| * This is distinct from the operator's own client: a {@code ClusterUser} holds a | ||
| * Kubernetes client scoped to the RBAC of a regular user, not the operator itself. | ||
| * Using this explicitly in tests makes clear which operations represent user actions | ||
| * and which represent operator reactions, which matters for reasoning about RBAC. | ||
| */ | ||
| public class ClusterUser { | ||
|
|
||
| private final KubernetesClient client; | ||
| private final String namespace; | ||
|
|
||
| ClusterUser(@NonNull KubernetesClient client, @NonNull String namespace) { | ||
| this.client = client; | ||
| this.namespace = namespace; | ||
| } | ||
|
|
||
| @NonNull | ||
| public <T extends HasMetadata> T create(@NonNull T resource) { | ||
| return client.resource(resource).inNamespace(namespace).create(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My Claude review suggested the following, which I think is a useful improvement. It'll help us understand clearly whose actions are failing when confronted by a dense test log. |
||
| } | ||
|
|
||
| @Nullable | ||
| public <T extends HasMetadata> T get(@NonNull Class<T> type, @NonNull String name) { | ||
| return client.resources(type).inNamespace(namespace).withName(name).get(); | ||
| } | ||
|
|
||
| @NonNull | ||
| public <T extends HasMetadata> T replace(@NonNull T resource) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude pointed out the replace/update inconsistency. I agree this is something worth sorting out, but maybe as a separate PR. Aside: yesterday I found myself wanting certainty about what fields are updated. I really wanted a patch to be sent to the server. I did this: |
||
| return client.resource(resource).inNamespace(namespace).update(); | ||
| } | ||
|
|
||
| public <T extends HasMetadata> boolean delete(@NonNull T resource) { | ||
| var result = client.resource(resource).inNamespace(namespace).delete(); | ||
| return result.size() == 1 && result.get(0).getCauses().isEmpty(); | ||
| } | ||
|
|
||
| @NonNull | ||
| public <T extends HasMetadata> NonNamespaceOperation<T, KubernetesResourceList<T>, Resource<T>> resources(@NonNull Class<T> type) { | ||
| return client.resources(type).inNamespace(namespace); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,10 +204,23 @@ public KubernetesClient operatorClient() { | |
| return new KubernetesClientBuilder().editOrNewConfig().withImpersonateUsername(impersonatedUser).endConfig().build(); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the Kubernetes client used for user-level interactions. | ||
| * <p> | ||
| * This client has the RBAC rights of a cluster user (via the admin client), not the operator. | ||
| * The client is created lazily on first call and closed in {@link #afterAll}. | ||
| */ | ||
| @NonNull | ||
| public KubernetesClient userClient() { | ||
| if (testActorClient == null) { | ||
| testActorClient = adminClientFactory.get(); | ||
| } | ||
| return testActorClient; | ||
| } | ||
|
|
||
| @NonNull | ||
| public TestActor testActor(@NonNull AbstractOperatorExtension operatorExtension) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the intent to remove TestActor entirely? |
||
| testActorClient = adminClientFactory.get(); | ||
| var client = testActorClient; | ||
| var client = userClient(); | ||
| return new TestActor() { | ||
|
|
||
| @NonNull | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| /* | ||
| * Copyright Kroxylicious Authors. | ||
| * | ||
| * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 | ||
| */ | ||
|
|
||
| package io.kroxylicious.kubernetes.operator; | ||
|
|
||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import io.fabric8.kubernetes.api.model.Secret; | ||
| import io.fabric8.kubernetes.api.model.SecretBuilder; | ||
| import io.fabric8.kubernetes.client.KubernetesClient; | ||
| import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; | ||
|
|
||
| import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy; | ||
| import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyBuilder; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| @EnableKubernetesMockClient(crud = true) | ||
| class ClusterUserTest { | ||
|
|
||
| private static final String NAMESPACE = "test-ns"; | ||
|
|
||
| KubernetesClient client; | ||
|
|
||
| @BeforeEach | ||
| void createNamespace() { | ||
| client.namespaces().resource(new io.fabric8.kubernetes.api.model.NamespaceBuilder() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what's cleaning up the namespce? |
||
| .withNewMetadata().withName(NAMESPACE).endMetadata().build()).create(); | ||
| } | ||
|
|
||
| @Test | ||
| void createPersistsResourceInNamespace() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
| KafkaProxy proxy = new KafkaProxyBuilder().withNewMetadata().withName("my-proxy").endMetadata().build(); | ||
|
|
||
| user.create(proxy); | ||
|
|
||
| assertThat(client.resources(KafkaProxy.class).inNamespace(NAMESPACE).withName("my-proxy").get()) | ||
| .isNotNull(); | ||
| } | ||
|
|
||
| @Test | ||
| void getReturnsResourceByName() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
| KafkaProxy proxy = new KafkaProxyBuilder().withNewMetadata().withName("my-proxy").endMetadata().build(); | ||
| client.resource(proxy).inNamespace(NAMESPACE).create(); | ||
|
|
||
| KafkaProxy result = user.get(KafkaProxy.class, "my-proxy"); | ||
|
|
||
| assertThat(result).isNotNull(); | ||
| assertThat(result.getMetadata().getName()).isEqualTo("my-proxy"); | ||
| } | ||
|
|
||
| @Test | ||
| void getMissingResourceReturnsNull() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
|
|
||
| assertThat(user.get(KafkaProxy.class, "does-not-exist")).isNull(); | ||
| } | ||
|
|
||
| @Test | ||
| void replaceUpdatesExistingResource() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
| Secret secret = new SecretBuilder().withNewMetadata().withName("my-secret").endMetadata() | ||
| .addToStringData("key", "original").build(); | ||
| client.resource(secret).inNamespace(NAMESPACE).create(); | ||
|
|
||
| Secret updated = new SecretBuilder(secret).addToStringData("key", "updated").build(); | ||
| user.replace(updated); | ||
|
|
||
| Secret fetched = client.resources(Secret.class).inNamespace(NAMESPACE).withName("my-secret").get(); | ||
| assertThat(fetched.getStringData()).containsEntry("key", "updated"); | ||
| } | ||
|
|
||
| @Test | ||
| void deleteRemovesResourceFromNamespace() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
| KafkaProxy proxy = new KafkaProxyBuilder().withNewMetadata().withName("my-proxy").endMetadata().build(); | ||
| client.resource(proxy).inNamespace(NAMESPACE).create(); | ||
|
|
||
| user.delete(proxy); | ||
|
|
||
| assertThat(client.resources(KafkaProxy.class).inNamespace(NAMESPACE).withName("my-proxy").get()) | ||
| .isNull(); | ||
| } | ||
|
|
||
| @Test | ||
| void resourcesReturnsNamespaceScopedOperation() { | ||
| var user = new ClusterUser(client, NAMESPACE); | ||
| KafkaProxy proxy = new KafkaProxyBuilder().withNewMetadata().withName("my-proxy").endMetadata().build(); | ||
| client.resource(proxy).inNamespace(NAMESPACE).create(); | ||
|
|
||
| var items = user.resources(KafkaProxy.class).list().getItems(); | ||
|
|
||
| assertThat(items).hasSize(1); | ||
| assertThat(items.get(0).getMetadata().getName()).isEqualTo("my-proxy"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's mention that the LKOE is the factory for this class.