Skip to content

Commit

Permalink
Merge pull request #17 from marcnuri-forks/feat/kubernetes-generic-re…
Browse files Browse the repository at this point in the history
…sources

feat(kubernetes): support for any apiVersion-lind resource
  • Loading branch information
maxandersen authored Feb 7, 2025
2 parents be8f6b4 + 059292c commit 1f3533c
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,76 @@ public String configuration_get() {
}
}

@Tool(description = "List Kubernetes resources in the current cluster by providing their apiVersion and kind and optionally the namespace")
public String resources_list(
@ToolArg(description = "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1") String apiVersion,
@ToolArg(description = "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)") String kind,
@ToolArg(description = "Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources)", required = false) String namespace
) {
try {
final var resource = kubernetesClient.genericKubernetesResources(apiVersion, kind);
if (namespace != null && !namespace.isBlank()) {
return asJson(resource.inNamespace(namespace).list().getItems());
}
try {
return asJson(resource.inAnyNamespace().list().getItems());
} catch (Exception e) {
return asJson(resource.list().getItems());
}
} catch (Exception e) {
throw new ToolCallException("Failed to get resources for " + apiVersion + " " + kind + ": " + e.getMessage(), e);
}
}

@Tool(description = "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name")
public String resources_get(
@ToolArg(description = "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1") String apiVersion,
@ToolArg(description = "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)") String kind,
@ToolArg(description = "Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources)", required = false) String namespace,
@ToolArg(description = "Name of the resource", required = false) String name
) {
try {
return asJson(
kubernetesClient.genericKubernetesResources(apiVersion, kind)
.inNamespace(namespace == null ? kubernetesClient.getNamespace() : namespace)
.withName(name)
.get()
);
} catch (Exception e) {
throw new ToolCallException("Failed to get the resource for " + apiVersion + " " + kind + ": " + e.getMessage(), e);
}
}

@Tool(description = "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource")
public String resources_create_or_update(
@ToolArg(description = "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec") String resource
) {
try {
return asJson(kubernetesClient.resource(resource).createOr(NonDeletingOperation::update));
} catch (Exception e) {
throw new ToolCallException("Failed to create or update the resource: " + e.getMessage(), e);
}
}

@Tool(description = "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name")
public String resources_delete(
@ToolArg(description = "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1") String apiVersion,
@ToolArg(description = "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)") String kind,
@ToolArg(description = "Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources)", required = false) String namespace,
@ToolArg(description = "Name of the resource", required = false) String name
) {
try {
kubernetesClient.genericKubernetesResources(apiVersion, kind)
.inNamespace(namespace == null ? kubernetesClient.getNamespace() : namespace)
.withName(name)
.withTimeout(10, TimeUnit.SECONDS)
.delete();
return "Resource deleted successfully";
} catch (Exception e) {
throw new ToolCallException("Failed to delete the resource for " + apiVersion + " " + kind + ": " + e.getMessage(), e);
}
}

@Tool(description = "List all the Kubernetes namespaces in the current cluster")
public String namespaces_list() {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package io.quarkus.mcp.servers.kubernetes;

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.api.model.Namespace;
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
import io.fabric8.kubernetes.api.model.Node;
import io.fabric8.kubernetes.api.model.NodeBuilder;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.ServiceAccountBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.dsl.NonDeletingOperation;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
Expand Down Expand Up @@ -40,6 +45,92 @@ void configuration_get_returnsTestKubernetesMasterUrl() {
.startsWith("https://localhost:");
}

@Nested
class GenericResourceOperations {

@Test
void resources_list_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-list").endMetadata().build())
.serverSideApply();
assertThat(unmarshalList(server.resources_list("v1", "Node", "ignored"), GenericKubernetesResource.class))
.extracting("kind", "metadata.name")
.contains(tuple("Node", "a-node-to-list"));
}

@Test
void resources_list_namespaceScopedAllNamespaces() {
kubernetesClient.namespaces()
.resource(new NamespaceBuilder().withNewMetadata().withName("other-namespace").endMetadata().build())
.serverSideApply();
kubernetesClient.configMaps()
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-list").endMetadata().build())
.serverSideApply();
kubernetesClient.configMaps()
.inNamespace("other-namespace")
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-list-in-other-namespace").endMetadata().build())
.serverSideApply();
assertThat(unmarshalList(server.resources_list("v1", "ConfigMap", null), GenericKubernetesResource.class))
.extracting("kind", "metadata.namespace", "metadata.name")
.contains(
tuple("ConfigMap", "default", "a-configmap-to-list"),
tuple("ConfigMap", "other-namespace", "a-configmap-to-list-in-other-namespace")
);
}

@Test
void resources_get_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-get").endMetadata().build())
.serverSideApply();
assertThat(unmarshal(server.resources_get("v1", "Node", "ignored", "a-node-to-get"), Node.class))
.hasFieldOrPropertyWithValue("metadata.name", "a-node-to-get");
}

@Test
void resources_get_namespaceScoped() {
kubernetesClient.configMaps()
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-get").endMetadata().build())
.serverSideApply();
assertThat(unmarshal(server.resources_get("v1", "ConfigMap", "default", "a-configmap-to-get"), ConfigMap.class))
.hasFieldOrPropertyWithValue("metadata.name", "a-configmap-to-get");
}

@Test
void resources_create_or_update_clusterScopedWithIgnoredNamespace() {
assertThat(server.resources_create_or_update("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"metadata\":{\"name\":\"a-node-to-create\"}}"))
.startsWith("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"metadata\":{");
assertThat(kubernetesClient.nodes().withName("a-node-to-create").get())
.hasFieldOrPropertyWithValue("metadata.name", "a-node-to-create");
}

@Test
void resources_create_or_update_namespaceScoped() {
assertThat(server.resources_create_or_update("{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"a-configmap-to-create\"}}"))
.startsWith("{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{");
assertThat(kubernetesClient.configMaps().inNamespace("default").withName("a-configmap-to-create").get())
.hasFieldOrPropertyWithValue("metadata.name", "a-configmap-to-create");
}

@Test
void resources_delete_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-delete").endMetadata().build())
.serverSideApply();
assertThat(server.resources_delete("v1", "Node", "ignored", "a-node-to-delete"))
.isEqualTo("Resource deleted successfully");
}

@Test
void resources_delete_namespaceScoped() {
kubernetesClient.configMaps()
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-delete").endMetadata().build())
.serverSideApply();
assertThat(server.resources_delete("v1", "ConfigMap", "default", "a-configmap-to-delete"))
.isEqualTo("Resource deleted successfully");
}
}

@Test
void namespaces_list() {
kubernetesClient.namespaces()
Expand All @@ -53,11 +144,15 @@ void namespaces_list() {
@Nested
class PodOperations {

@Test
void pods_list() {
@BeforeEach
void setRequiredServiceAccount() {
kubernetesClient
.resource(new ServiceAccountBuilder().withNewMetadata().withName("default").endMetadata().build())
.createOr(NonDeletingOperation::update);
}

@Test
void pods_list() {
kubernetesClient.run()
.withName("a-pod-to-list")
.withImage("busybox")
Expand All @@ -69,9 +164,6 @@ void pods_list() {

@Test
void pods_list_in_namespace() {
kubernetesClient
.resource(new ServiceAccountBuilder().withNewMetadata().withName("default").endMetadata().build())
.createOr(NonDeletingOperation::update);
kubernetesClient.run()
.withName("a-pod-to-list-in-namespace")
.withImage("busybox")
Expand All @@ -83,9 +175,6 @@ void pods_list_in_namespace() {

@Test
void pods_get() {
kubernetesClient
.resource(new ServiceAccountBuilder().withNewMetadata().withName("default").endMetadata().build())
.createOr(NonDeletingOperation::update);
kubernetesClient.run()
.withName("a-pod-to-get")
.withImage("busybox")
Expand All @@ -97,9 +186,6 @@ void pods_get() {

@Test
void pods_delete() {
kubernetesClient
.resource(new ServiceAccountBuilder().withNewMetadata().withName("default").endMetadata().build())
.createOr(NonDeletingOperation::update);
kubernetesClient.run()
.withName("a-pod-to-delete")
.withImage("busybox")
Expand All @@ -110,9 +196,6 @@ void pods_delete() {

@Test
void pods_log() {
kubernetesClient
.resource(new ServiceAccountBuilder().withNewMetadata().withName("default").endMetadata().build())
.createOr(NonDeletingOperation::update);
kubernetesClient.run()
.withName("a-pod-to-log")
.withImage("busybox")
Expand All @@ -138,6 +221,16 @@ void pods_run_returnsPodInfo() {
tuple("Pod", "a-pod-to-run-2")
);
}

@Test
void pods_run_returnsServiceInfo() {
assertThat(unmarshalList(server.pods_run("default", "a-pod-to-run-with-service", "busybox", 8080), GenericKubernetesResource.class))
.extracting("kind", "metadata.name")
.contains(
tuple("Pod", "a-pod-to-run-with-service"),
tuple("Service", "a-pod-to-run-with-service")
);
}
}

@SuppressWarnings("unchecked")
Expand Down

0 comments on commit 1f3533c

Please sign in to comment.