Skip to content

Commit

Permalink
feat(kubernetes-client-api): add pod ephemeral container operations
Browse files Browse the repository at this point in the history
Add new interface EphemeralContainersResource to expose operations on this
new operation context and is returned by a new method on PodResource.

OperationContext and OperationSupport have been updated to support implementing operations on named resource sub-resources.

Fixes fabric8io#4758
  • Loading branch information
cronik committed Jan 12, 2023
1 parent a4db144 commit c2cb267
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 23 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#### Dependency Upgrade

#### New Features
* Fix #4758: added support for pod ephemeral container operations

#### _**Note**_: Breaking changes
* Fix #3972: deprecated Parameterizable and methods on Serialization accepting parameters - that was only needed as a workaround for non-string parameters. You should instead include those parameter values in the map passed to processLocally.
Expand Down
30 changes: 30 additions & 0 deletions doc/CHEATSHEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,36 @@ deleteLatch.await(10, TimeUnit.MINUTES)
String result = new BufferedReader(new InputStreamReader(is)).lines().collect(Collectors.joining("\n"));
}
```
- Add ephemeral container to a `Pod`
```java
PodResource resource = client.pods().withName("pod1");
resource.ephemeralContainers()
.edit(p -> new PodBuilder(p)
.editSpec()
.addNewEphemeralContainer()
.withName("debugger")
.withImage("busybox")
.withCommand("sleep", "36000")
.endEphemeralContainer()
.endSpec()
.build());

resource.waitUntilCondition(p -> {
return p.getStatus()
.getEphemeralContainerStatuses()
.stream()
.filter(s -> s.getName().equals("debugger"))
.anyMatch(s -> s.getState().getRunning() != null);
}, 2, TimeUnit.MINUTES);

ByteArrayOutputStream out = new ByteArrayOutputStream();
try (ExecWatch watch = resource.inContainer("debugger")
.writingOutput(out)
.exec("sh", "-c", "echo 'hello world!'")) {
assertEquals(0, watch.exitCode().join());
assertEquals("hello world!\n", out.toString());
}
```
- Using Kubernetes Client from within a `Pod`
When trying to access Kubernetes API from within a `Pod` authentication is done a bit differently as compared to when being done on your system. If you checkout [documentation](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/#accessing-the-api-from-a-pod). Client authenticates by reading `ServiceAccount` from `/var/run/secrets/kubernetes.io/serviceaccount/` and reads environment variables like `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` for apiServer URL. You don't have to worry about all this when using Fabric8 Kubernetes Client. You can simply use it like this and client will take care of everything:
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.fabric8.kubernetes.client.dsl;

import io.fabric8.kubernetes.api.model.Pod;

/**
* Operations for Pod Ephemeral Containers. Ephemeral containers are a special type of container that runs temporarily
* in an existing Pod to accomplish user-initiated actions such as troubleshooting. You use ephemeral containers to
* inspect services rather than to build applications.
*
* @see <a href="https://kubernetes.io/docs/concepts/workloads/pods/ephemeral-containers/">About Ephemeral Containers</a>
* @see <a href=
* "hhttps://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#-strong-ephemeralcontainers-operations-pod-v1-core-strong-">Ephemeral
* Containers Operations</a>
*/
public interface EphemeralContainersResource extends EditReplacePatchable<Pod> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public interface PodResource extends Resource<Pod>,
Loggable,
Containerable<String, ContainerResource>,
ContainerResource,
EphemeralContainersResource,
PortForwardable {

/**
Expand All @@ -40,4 +41,12 @@ public interface PodResource extends Resource<Pod>,
* @throws io.fabric8.kubernetes.client.KubernetesClientException if an error occurs, including if the Pod is not found.
*/
boolean evict(Eviction eviction);

/**
* Manage ephemeral containers for a pod.
*
* @return ephemeral containers resource operations
*/
EphemeralContainersResource ephemeralContainers();

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public class OperationContext {
protected String namespace;
protected boolean defaultNamespace = true;
protected String name;
protected String subresource;
protected boolean dryRun;
protected FieldValidateable.Validation fieldValidation;
protected String fieldManager;
Expand All @@ -75,14 +76,14 @@ public OperationContext() {
}

public OperationContext(OperationContext other) {
this(other.client, other.plural, other.namespace, other.name, other.apiGroupName, other.apiGroupVersion,
this(other.client, other.plural, other.namespace, other.name, other.subresource, other.apiGroupName, other.apiGroupVersion,
other.item, other.labels, other.labelsNot, other.labelsIn, other.labelsNotIn, other.fields,
other.fieldsNot, other.resourceVersion, other.gracePeriodSeconds, other.propagationPolicy,
other.dryRun, other.selectorAsString, other.defaultNamespace, other.fieldValidation, other.fieldManager,
other.forceConflicts, other.timeout, other.timeoutUnit);
}

public OperationContext(Client client, String plural, String namespace, String name,
public OperationContext(Client client, String plural, String namespace, String name, String subresource,
String apiGroupName, String apiGroupVersion, Object item, Map<String, String> labels,
Map<String, String[]> labelsNot, Map<String, String[]> labelsIn, Map<String, String[]> labelsNotIn,
Map<String, String> fields, Map<String, String[]> fieldsNot, String resourceVersion,
Expand All @@ -94,6 +95,7 @@ public OperationContext(Client client, String plural, String namespace, String n
this.plural = plural;
setNamespace(namespace, defaultNamespace);
this.name = name;
this.subresource = subresource;
setApiGroupName(apiGroupName);
setApiGroupVersion(apiGroupVersion);
setLabels(labels);
Expand Down Expand Up @@ -191,6 +193,10 @@ public String getName() {
return name;
}

public String getSubresource() {
return subresource;
}

public String getApiGroupName() {
return apiGroupName;
}
Expand Down Expand Up @@ -349,6 +355,15 @@ public OperationContext withName(String name) {
return context;
}

public OperationContext withSubresource(String subresource) {
if (Objects.equals(this.subresource, subresource)) {
return this;
}
final OperationContext context = new OperationContext(this);
context.subresource = subresource;
return context;
}

public OperationContext withApiGroupName(String apiGroupName) {
if (Objects.equals(this.apiGroupName, apiGroupName)) {
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public class OperationSupport {
protected final String resourceT;
protected String namespace;
protected String name;
protected String subresource;
protected String apiGroupName;
protected String apiGroupVersion;
protected boolean dryRun;
Expand All @@ -99,6 +100,7 @@ public OperationSupport(OperationContext ctx) {
this.resourceT = ctx.getPlural();
this.namespace = ctx.getNamespace();
this.name = ctx.getName();
this.subresource = ctx.getSubresource();
this.apiGroupName = ctx.getApiGroupName();
this.dryRun = ctx.getDryRun();
if (Utils.isNotNullOrEmpty(ctx.getApiGroupVersion())) {
Expand Down Expand Up @@ -174,35 +176,44 @@ protected void addNamespacedUrlPathParts(List<String> parts, String namespace, S
parts.add("namespaces");
parts.add(namespace);
}
parts.add(type);

if (Utils.isNotNullOrEmpty(type)) {
parts.add(type);
}
}

public URL getNamespacedUrl() throws MalformedURLException {
return getNamespacedUrl(getNamespace());
}

public URL getResourceUrl(String namespace, String name) throws MalformedURLException {
return getResourceUrl(namespace, name, false);
}

public URL getResourceUrl(String namespace, String name, boolean status) throws MalformedURLException {
public URL getResourceUrl(String namespace, String name, String... subresources) throws MalformedURLException {
String subresource = URLUtils.pathJoin(subresources);
if (name == null) {
if (status) {
if (Utils.isNotNullOrEmpty(subresource)) {
throw new KubernetesClientException("name not specified for an operation requiring one.");
}

return getNamespacedUrl(namespace);
}

String path = name;
if (Utils.isNotNullOrEmpty(subresource)) {
path = URLUtils.pathJoin(path, subresource);
}

return new URL(URLUtils.join(getNamespacedUrl(namespace).toString(), path));
}

public URL getResourceUrl(String namespace, String name, boolean status) throws MalformedURLException {
if (status) {
return new URL(URLUtils.join(getNamespacedUrl(namespace).toString(), name, "status"));
return getResourceUrl(namespace, name, "status");
}
return new URL(URLUtils.join(getNamespacedUrl(namespace).toString(), name));

return getResourceUrl(namespace, name, subresource);
}

public URL getResourceUrl() throws MalformedURLException {
if (name == null) {
return getNamespacedUrl();
}
return new URL(URLUtils.join(getNamespacedUrl().toString(), name));
return getResourceUrl(namespace, name, subresource);
}

public URL getResourceURLForWriteOperation(URL resourceURL) throws MalformedURLException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@

import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.DeleteOptions;
import io.fabric8.kubernetes.api.model.EphemeralContainer;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.PodList;
import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.policy.v1beta1.Eviction;
import io.fabric8.kubernetes.api.model.policy.v1beta1.EvictionBuilder;
import io.fabric8.kubernetes.client.Client;
Expand All @@ -28,6 +30,7 @@
import io.fabric8.kubernetes.client.PortForward;
import io.fabric8.kubernetes.client.dsl.BytesLimitTerminateTimeTailPrettyLoggable;
import io.fabric8.kubernetes.client.dsl.CopyOrReadable;
import io.fabric8.kubernetes.client.dsl.EphemeralContainersResource;
import io.fabric8.kubernetes.client.dsl.ExecListenable;
import io.fabric8.kubernetes.client.dsl.ExecListener;
import io.fabric8.kubernetes.client.dsl.ExecWatch;
Expand Down Expand Up @@ -81,6 +84,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
Expand All @@ -89,7 +93,7 @@
import static io.fabric8.kubernetes.client.utils.internal.OptionalDependencyWrapper.wrapRunWithOptionalDependency;

public class PodOperationsImpl extends HasMetadataOperation<Pod, PodList, PodResource>
implements PodResource, CopyOrReadable {
implements PodResource, EphemeralContainersResource, CopyOrReadable {

public static final int HTTP_TOO_MANY_REQUESTS = 429;
private static final Integer DEFAULT_POD_READY_WAIT_TIMEOUT = 5;
Expand Down Expand Up @@ -271,6 +275,11 @@ private boolean handleEvict(HasMetadata eviction) {
}
}

@Override
public EphemeralContainersResource ephemeralContainers() {
return new PodOperationsImpl(getContext(), context.withSubresource("ephemeralcontainers"));
}

@Override
public PodOperationsImpl inContainer(
String containerId) {
Expand Down Expand Up @@ -324,22 +333,34 @@ String validateOrDefaultContainerId(String name, Pod pod) {
if (pod == null) {
pod = this.getItemOrRequireFromServer();
}

List<Container> containers = Collections.emptyList();
List<EphemeralContainer> ephemeralContainers = Collections.emptyList();
// spec and container null-checks are not necessary for real k8s clusters, added them to simplify some tests running in the mockserver
if (pod.getSpec() == null || pod.getSpec().getContainers() == null || pod.getSpec().getContainers().isEmpty()) {
throw new KubernetesClientException("Pod has no containers!");
PodSpec spec = pod.getSpec();
if (pod.getSpec() != null) {
if (spec.getContainers() != null) {
containers = spec.getContainers();
}

if (spec.getEphemeralContainers() != null) {
ephemeralContainers = spec.getEphemeralContainers();
}
}
final List<Container> containers = pod.getSpec().getContainers();

if (name == null) {
name = pod.getMetadata().getAnnotations().get(DEFAULT_CONTAINER_ANNOTATION_NAME);
if (name != null && !hasContainer(containers, name)) {
LOG.warn("Default container {} from annotation not found in pod {}", name, pod.getMetadata().getName());
name = null;
}
if (name == null) {
if (name == null && !containers.isEmpty()) {
name = containers.get(0).getName();
LOG.debug("using first container {} in pod {}", name, pod.getMetadata().getName());
} else {
throw new KubernetesClientException("Pod has no containers!");
}
} else if (!hasContainer(containers, name)) {
} else if (!hasContainer(containers, name) && !hasEphemeralContainer(ephemeralContainers, name)) {
throw new KubernetesClientException(
String.format("container %s not found in pod %s", name, pod.getMetadata().getName()));
}
Expand All @@ -350,6 +371,10 @@ private boolean hasContainer(List<Container> containers, String toFind) {
return containers.stream().map(Container::getName).anyMatch(s -> s.equals(toFind));
}

private boolean hasEphemeralContainer(List<EphemeralContainer> containers, String toFind) {
return containers.stream().map(EphemeralContainer::getName).anyMatch(s -> s.equals(toFind));
}

private ExecWebSocketListener setupConnectionToPod(URI uri) {
HttpClient clone = httpClient.newBuilder().readTimeout(0, TimeUnit.MILLISECONDS).build();
ExecWebSocketListener execWebSocketListener = new ExecWebSocketListener(getContext(), this.context.getExecutor());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ void testCompleteOperationContext() {
// When
operationContext = operationContext.withNamespace("operation-namespace")
.withName("operand-name")
.withSubresource("subresource")
.withClient(client)
.withApiGroupName("batch")
.withApiGroupVersion("v1")
Expand All @@ -82,6 +83,7 @@ void testCompleteOperationContext() {
assertNotNull(operationContext);
assertEquals("operation-namespace", operationContext.getNamespace());
assertEquals("operand-name", operationContext.getName());
assertEquals("subresource", operationContext.getSubresource());
assertEquals("batch", operationContext.getApiGroupName());
assertEquals("v1", operationContext.getApiGroupVersion());
assertEquals("jobs", operationContext.getPlural());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
import io.fabric8.kubernetes.client.http.HttpResponse;
import io.fabric8.kubernetes.client.http.StandardHttpRequest;
import io.fabric8.kubernetes.client.http.TestHttpResponse;
import io.fabric8.kubernetes.client.utils.URLUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.net.MalformedURLException;
import java.net.URI;
import java.util.stream.Stream;

Expand Down Expand Up @@ -126,4 +128,49 @@ void assertResponseCodeClientErrorAndCustomMessage() throws Exception {
assertThat(result)
.hasMessageContaining("Failure executing: GET at: https://example.com. Message: Custom message Bad Request.");
}

@Test
void getResourceURL() throws MalformedURLException {
assertThat(operationSupport.getResourceUrl().toString()).isEqualTo("https://kubernetes.default.svc/api/v1");

OperationSupport pods = new OperationSupport(operationSupport.context.withPlural("pods"));
assertThat(pods.getResourceUrl().toString()).isEqualTo("https://kubernetes.default.svc/api/v1/pods");

pods = new OperationSupport(pods.context.withName("pod-1"));
assertThat(pods.getResourceUrl().toString()).isEqualTo("https://kubernetes.default.svc/api/v1/pods/pod-1");

pods = new OperationSupport(pods.context.withSubresource("ephemeralcontainers"));
assertThat(pods.getResourceUrl().toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/pods/pod-1/ephemeralcontainers");

pods = new OperationSupport(pods.context.withNamespace("default"));
assertThat(pods.getResourceUrl().toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/ephemeralcontainers");

assertThrows(KubernetesClientException.class, () -> {
OperationSupport subresourceWithoutName = new OperationSupport(
operationSupport.context.withPlural("Pods").withSubresource("pod-1"));
subresourceWithoutName.getResourceUrl();
});
}

@Test
void getResourceURLStatus() throws MalformedURLException {
OperationSupport pods = new OperationSupport(operationSupport.context.withPlural("pods"));
assertThat(pods.getResourceUrl("default", "pod-1", true).toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/status");
assertThat(pods.getResourceUrl("default", "pod-1", false).toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1");

OperationSupport podsSubresource = new OperationSupport(pods.context.withSubresource("ephemeralcontainers"));
assertThat(podsSubresource.getResourceUrl("default", "pod-1", true).toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/status");
assertThat(podsSubresource.getResourceUrl("default", "pod-1", false).toString())
.isEqualTo("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/ephemeralcontainers");

assertThrows(KubernetesClientException.class, () -> {
operationSupport.getResourceUrl("default", null, true);
}, "status requires name");
}

}
Loading

0 comments on commit c2cb267

Please sign in to comment.