Skip to content

Commit

Permalink
chore: Add test action that connects local test to a Kubernetes service
Browse files Browse the repository at this point in the history
- Uses Kubernetes client port forward to connect a local port with an exposed port on a service or pod running on Kubernetes
- Enables users to invoke the Kubernetes service from the local host
  • Loading branch information
christophd committed Feb 20, 2025
1 parent 3a2062c commit 0be28df
Show file tree
Hide file tree
Showing 27 changed files with 1,561 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import io.fabric8.kubernetes.api.model.NamedContext;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import org.citrusframework.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -149,6 +153,14 @@ public static String getNamespace() {
}
}

try (final KubernetesClient k8s = new KubernetesClientBuilder().build()) {
NamedContext currentContext = k8s.getConfiguration().getCurrentContext();
if (currentContext != null && currentContext.getContext() != null && StringUtils.hasText(currentContext.getContext().getNamespace())) {
logger.debug("Reading current namespace from context: {}", currentContext.getName());
return currentContext.getContext().getNamespace();
}
}

return NAMESPACE_DEFAULT;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.citrusframework.kubernetes.actions;

import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import org.citrusframework.AbstractTestActionBuilder;
import org.citrusframework.actions.AbstractTestAction;
import org.citrusframework.kubernetes.KubernetesActor;
Expand Down Expand Up @@ -105,6 +106,11 @@ public final T build() {
if (kubernetesClient == null) {
if (referenceResolver != null && referenceResolver.isResolvable(KubernetesClient.class)) {
kubernetesClient = referenceResolver.resolve(KubernetesClient.class);
} else {
kubernetesClient = new KubernetesClientBuilder().build();
if (referenceResolver != null) {
referenceResolver.bind("kubernetesClient", kubernetesClient);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,30 @@ public DeleteResourceAction.Builder delete(String content) {

public class ServiceActionBuilder {

/**
* Connect to given Kubernetes service via local port forward.
* @param serviceName the name of the Kubernetes service.
*/
public ServiceConnectAction.Builder connect(String serviceName) {
ServiceConnectAction.Builder builder = new ServiceConnectAction.Builder()
.client(kubernetesClient)
.service(serviceName);
delegate = builder;
return builder;
}

/**
* Connect to given Kubernetes service via local port forward.
* @param serviceName the name of the Kubernetes service.
*/
public ServiceDisconnectAction.Builder disconnect(String serviceName) {
ServiceDisconnectAction.Builder builder = new ServiceDisconnectAction.Builder()
.client(kubernetesClient)
.service(serviceName);
delegate = builder;
return builder;
}

/**
* Create service instance.
* @param serviceName the name of the Kubernetes service.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.citrusframework.kubernetes.actions;

import io.fabric8.kubernetes.client.LocalPortForward;
import org.citrusframework.context.TestContext;
import org.citrusframework.exceptions.CitrusRuntimeException;
import org.citrusframework.http.client.HttpClient;
import org.citrusframework.http.client.HttpClientBuilder;
import org.citrusframework.http.server.HttpServer;
import org.citrusframework.kubernetes.KubernetesSettings;
import org.citrusframework.util.StringUtils;

import static org.citrusframework.kubernetes.actions.KubernetesActionBuilder.kubernetes;

/**
* Action connects the test to a Kubernetes service so clients may invoke the service.
* This is for services that are only accessible from within the cluster (e.g. service type is ClusterIP).
* The test action connects to the service via port forwarding and exposes a Http client to the Citrus context
* so other test actions can access the service running in Kubernetes.
*/
public class ServiceConnectAction extends AbstractKubernetesAction {

private final String clientName;
private final String serviceName;
private final String port;
private final String localPort;

public ServiceConnectAction(Builder builder) {
super("service-connect", builder);

this.serviceName = builder.serviceName;
this.port = builder.port;
this.clientName = builder.clientName;
this.localPort = builder.localPort;
}

@Override
public void doExecute(TestContext context) {
if (KubernetesSettings.isLocal()) {
if (context.getReferenceResolver().isResolvable(serviceName)) {
HttpServer server = context.getReferenceResolver().resolve(serviceName, HttpServer.class);
HttpClient serviceClient = new HttpClientBuilder()
.requestUrl("http://localhost:%d".formatted(server.getPort()))
.build();
context.getReferenceResolver().bind(clientName, serviceClient);
}

return;
}

LocalPortForward portForward;
if (StringUtils.hasText(localPort)) {
portForward = getKubernetesClient().services()
.inNamespace(namespace(context))
.withName(serviceName)
.portForward(Integer.parseInt(context.replaceDynamicContentInString(port)), Integer.parseInt(context.replaceDynamicContentInString(localPort)));
} else {
portForward = getKubernetesClient().services()
.inNamespace(namespace(context))
.withName(serviceName)
.portForward(Integer.parseInt(context.replaceDynamicContentInString(port)));
}

if (context.getReferenceResolver().isResolvable(clientName)) {
throw new CitrusRuntimeException("Failed to bind Kubernetes service client '%s' - client already exists".formatted(clientName));
}

HttpClient serviceClient = new HttpClientBuilder()
.requestUrl("http://localhost:%d".formatted(portForward.getLocalPort()))
.build();
context.getReferenceResolver().bind(clientName, serviceClient);

if (context.getReferenceResolver().isResolvable(serviceName + ":port-forward")) {
throw new CitrusRuntimeException("Failed to bind Kubernetes service port forward '%s' - already exists".formatted(serviceName + ":port-forward"));
}
context.getReferenceResolver().bind(serviceName + ":port-forward", portForward);

if (isAutoRemoveResources()) {
context.doFinally(kubernetes().client(getKubernetesClient())
.services()
.disconnect(serviceName)
.inNamespace(getNamespace()));
}
}

/**
* Action builder.
*/
public static class Builder extends AbstractKubernetesAction.Builder<ServiceConnectAction, Builder> {

private String clientName;
private String localPort;
private String serviceName = KubernetesSettings.getServiceName();
private String port;

public Builder service(String serviceName) {
this.serviceName = serviceName;
return this;
}

public Builder client(String clientName) {
this.clientName = clientName;
return this;
}

public Builder port(String port) {
this.port = port;
return this;
}

public Builder port(int port) {
this.port = String.valueOf(port);
return this;
}

public Builder portMapping(String port, String localPort) {
if (port != null) {
port(port);
}

if (localPort != null) {
localPort(localPort);
}
return this;
}

public Builder portMapping(int port, int localPort) {
port(port);
localPort(localPort);
return this;
}

public Builder localPort(String localPort) {
this.localPort = localPort;
return this;
}

public Builder localPort(int localPort) {
this.localPort = String.valueOf(localPort);
return this;
}

@Override
public ServiceConnectAction doBuild() {
if (clientName == null) {
client(serviceName + ".client");
}

return new ServiceConnectAction(this);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.citrusframework.kubernetes.actions;

import java.io.IOException;

import io.fabric8.kubernetes.client.LocalPortForward;
import org.citrusframework.context.TestContext;
import org.citrusframework.kubernetes.KubernetesSettings;

/**
* Action closes port forward for a Kubernetes service.
* Resolves port forward from Citrus context and closes it when still alive.
*/
public class ServiceDisconnectAction extends AbstractKubernetesAction {

private final String serviceName;

public ServiceDisconnectAction(Builder builder) {
super("service-disconnect", builder);

this.serviceName = builder.serviceName;
}

@Override
public void doExecute(TestContext context) {
logger.info("Disconnect from Kubernetes service '{}'", serviceName);

if (KubernetesSettings.isLocal()) {
return;
}

if (context.getReferenceResolver().isResolvable(serviceName + ":port-forward", LocalPortForward.class)) {
LocalPortForward portForward = context.getReferenceResolver().resolve(serviceName + ":port-forward", LocalPortForward.class);
try {
if (portForward.isAlive()) {
portForward.close();
logger.info("Successfully disconnected from Kubernetes service '{}'", serviceName);
}
} catch (IOException e) {
logger.warn("Failed to close local port forward for Kubernetes service '{}'", serviceName);
}
} else {
logger.warn("Failed to disconnect from Kubernetes service '{}' - no port forward available", serviceName);
}
}

/**
* Action builder.
*/
public static class Builder extends AbstractKubernetesAction.Builder<ServiceDisconnectAction, Builder> {

private String serviceName = KubernetesSettings.getServiceName();

public Builder service(String serviceName) {
this.serviceName = serviceName;
return this;
}

@Override
public ServiceDisconnectAction doBuild() {
return new ServiceDisconnectAction(this);
}
}
}
Loading

0 comments on commit 0be28df

Please sign in to comment.