Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Copy Markdown

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.

*/
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

  7. Improve error context
  When operations fail, exceptions won't indicate they came from a "user-scoped" operation vs. operator operations. Consider wrapping exceptions with context:
  try {
      return client.resource(resource).inNamespace(namespace).create();
  } catch (KubernetesClientException e) {
      throw new KubernetesClientException("ClusterUser failed to create " + resource.getKind() + "/" + resource.getMetadata().getName(), e);
  }
  This makes debugging test failures easier.

}

@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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

kroxylicious@8400dac

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
Expand Up @@ -29,10 +29,7 @@

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.KubernetesResourceList;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
import io.fabric8.kubernetes.client.dsl.Resource;
import io.fabric8.kubernetes.client.readiness.Readiness;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
Expand All @@ -45,7 +42,6 @@
import io.kroxylicious.proxy.tag.VisibleForTesting;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;

/**
* A JUnit 5 extension that runs a Kroxylicious operator locally for integration testing.
Expand Down Expand Up @@ -264,21 +260,17 @@ public String getNamespace() {
return localOperatorExtension.getNamespace();
}

// ---- resource operations ----

@NonNull
public <T extends HasMetadata> T create(@NonNull T resource) {
return testActor.create(resource);
}

@Nullable
public <T extends HasMetadata> T get(@NonNull Class<T> type, @NonNull String name) {
return testActor.get(type, name);
}

/**
* Returns a {@link ClusterUser} backed by the admin Kubernetes client, scoped to the test namespace.
* <p>
* Use this to represent user-side interactions (creating CRs, reading state) as opposed to
* operator reactions. The operator is the system-under-test; the {@code ClusterUser} is the actor.
* <p>
* Must be called after {@code beforeAll}.
*/
@NonNull
public <T extends HasMetadata> T replace(@NonNull T resource) {
return testActor.replace(resource);
public ClusterUser clusterUser() {
return new ClusterUser(rbacHandler.userClient(), localOperatorExtension.getNamespace());
}

@NonNull
Expand All @@ -305,14 +297,6 @@ public <T extends HasMetadata> T updateStatus(@NonNull Class<T> type, @NonNull S
return testActor.patchStatus(mutated);
}

public <T extends HasMetadata> boolean delete(@NonNull T resource) {
return testActor.delete(resource);
}

@NonNull
public <T extends HasMetadata> NonNamespaceOperation<T, KubernetesResourceList<T>, Resource<T>> resources(@NonNull Class<T> type) {
return testActor.resources(type);
}

public static Builder builder() {
return new Builder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Expand Down
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.fabric8.kubernetes.api.model.KubernetesResourceList;
import io.fabric8.kubernetes.api.model.Namespace;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
Expand Down Expand Up @@ -236,6 +237,16 @@ void updateStatusRefetchesBeforeApplyingMutator() throws Exception {
assertThat(result).isSameAs(patched);
}

@Test
void clusterUserIsNonNullAfterBeforeAll() throws Exception {
when(locallyRunOperatorExtension.getNamespace()).thenReturn("test-ns");
when(rbacHandler.userClient()).thenReturn(mock(KubernetesClient.class));

extension.beforeAll(context);

assertThat(extension.clusterUser()).isNotNull();
}

// ---- helpers ----

private LocalKroxyliciousOperatorExtension.Builder defaultBuilder() {
Expand Down
Loading
Loading