diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2a025cde0..ede0316768a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/doc/CHEATSHEET.md b/doc/CHEATSHEET.md index 68ef2b85279..92efb760d09 100644 --- a/doc/CHEATSHEET.md +++ b/doc/CHEATSHEET.md @@ -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: ``` diff --git a/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusMessage.java b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusMessage.java new file mode 100644 index 00000000000..99e20b0198d --- /dev/null +++ b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusMessage.java @@ -0,0 +1,61 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import io.fabric8.kubernetes.api.model.Status; +import io.fabric8.kubernetes.api.model.StatusBuilder; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.fabric8.mockwebserver.internal.WebSocketMessage; + +import java.nio.charset.StandardCharsets; + +/** + * {@link WebSocketMessage} that outputs a {@link Status} message. + */ +public class StatusMessage extends WebSocketMessage { + + private static final byte ERROR_CHANNEL_STREAM_ID = 3; + + /** + * Create websocket message that returns {@link Status} on the error channel. + * + * @param status status response + */ + public StatusMessage(Status status) { + super(0L, getBodyBytes(ERROR_CHANNEL_STREAM_ID, status), true, true); + } + + /** + * Create websocket message that returns a {@link Status} with the given status code + * on the error channel. + * + * @param exitCode exit code + */ + public StatusMessage(int exitCode) { + this(new StatusBuilder() + .withCode(exitCode) + .withStatus(exitCode == 0 ? "Success" : "Failure") + .build()); + } + + private static byte[] getBodyBytes(byte prefix, Status status) { + byte[] original = Serialization.asJson(status).getBytes(StandardCharsets.UTF_8); + byte[] prefixed = new byte[original.length + 1]; + prefixed[0] = prefix; + System.arraycopy(original, 0, prefixed, 1, original.length); + return prefixed; + } +} diff --git a/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusMessageTest.java b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusMessageTest.java new file mode 100644 index 00000000000..cdc9770d332 --- /dev/null +++ b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusMessageTest.java @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class StatusMessageTest { + + @Test + void exitCode() { + assertEquals("\u0003{\"apiVersion\":\"v1\",\"kind\":\"Status\",\"code\":0,\"status\":\"Success\"}", + new StatusMessage(0).getBody()); + assertEquals("\u0003{\"apiVersion\":\"v1\",\"kind\":\"Status\",\"code\":1,\"status\":\"Failure\"}", + new StatusMessage(1).getBody()); + assertEquals("\u0003{\"apiVersion\":\"v1\",\"kind\":\"Status\",\"code\":-1,\"status\":\"Failure\"}", + new StatusMessage(-1).getBody()); + } +} diff --git a/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/EphemeralContainersResource.java b/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/EphemeralContainersResource.java new file mode 100644 index 00000000000..95453fbf512 --- /dev/null +++ b/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/EphemeralContainersResource.java @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 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 About Ephemeral Containers + * @see Ephemeral + * Containers Operations + */ +public interface EphemeralContainersResource extends EditReplacePatchable { +} diff --git a/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/PodResource.java b/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/PodResource.java index 851f37e4fa6..7cf598fdf7f 100644 --- a/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/PodResource.java +++ b/kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/dsl/PodResource.java @@ -22,6 +22,7 @@ public interface PodResource extends Resource, Loggable, Containerable, ContainerResource, + EphemeralContainersResource, PortForwardable { /** @@ -40,4 +41,12 @@ public interface PodResource extends Resource, * @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(); + } diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationContext.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationContext.java index 36fe63c0fd2..85e0737dbf8 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationContext.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationContext.java @@ -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; @@ -75,14 +76,15 @@ 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, + @SuppressWarnings("java:S107") + public OperationContext(Client client, String plural, String namespace, String name, String subresource, String apiGroupName, String apiGroupVersion, Object item, Map labels, Map labelsNot, Map labelsIn, Map labelsNotIn, Map fields, Map fieldsNot, String resourceVersion, @@ -94,6 +96,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); @@ -191,6 +194,10 @@ public String getName() { return name; } + public String getSubresource() { + return subresource; + } + public String getApiGroupName() { return apiGroupName; } @@ -349,6 +356,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; diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupport.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupport.java index 6e00568b9d4..088cf03d661 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupport.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupport.java @@ -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; @@ -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())) { @@ -174,35 +176,44 @@ protected void addNamespacedUrlPathParts(List 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 subresourcePath = URLUtils.pathJoin(subresources); if (name == null) { - if (status) { + if (Utils.isNotNullOrEmpty(subresourcePath)) { throw new KubernetesClientException("name not specified for an operation requiring one."); } + return getNamespacedUrl(namespace); } + + String path = name; + if (Utils.isNotNullOrEmpty(subresourcePath)) { + path = URLUtils.pathJoin(path, subresourcePath); + } + + 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 { diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/core/v1/PodOperationsImpl.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/core/v1/PodOperationsImpl.java index 19ed5db2cbb..bad160a0f11 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/core/v1/PodOperationsImpl.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/core/v1/PodOperationsImpl.java @@ -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; @@ -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; @@ -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; @@ -89,7 +93,7 @@ import static io.fabric8.kubernetes.client.utils.internal.OptionalDependencyWrapper.wrapRunWithOptionalDependency; public class PodOperationsImpl extends HasMetadataOperation - 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; @@ -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) { @@ -324,22 +333,32 @@ String validateOrDefaultContainerId(String name, Pod pod) { if (pod == null) { pod = this.getItemOrRequireFromServer(); } + + List containers = Collections.emptyList(); + List 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) { + containers = spec.getContainers(); + ephemeralContainers = spec.getEphemeralContainers(); } - final List containers = pod.getSpec().getContainers(); + if (name == null) { + if (containers == null || containers.isEmpty()) { + throw new KubernetesClientException("Pod has no containers!"); + } + 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) { name = containers.get(0).getName(); LOG.debug("using first container {} in pod {}", name, pod.getMetadata().getName()); } - } 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())); } @@ -347,7 +366,11 @@ String validateOrDefaultContainerId(String name, Pod pod) { } private boolean hasContainer(List containers, String toFind) { - return containers.stream().map(Container::getName).anyMatch(s -> s.equals(toFind)); + return containers != null && containers.stream().map(Container::getName).anyMatch(s -> s.equals(toFind)); + } + + private boolean hasEphemeralContainer(List containers, String toFind) { + return containers != null && containers.stream().map(EphemeralContainer::getName).anyMatch(s -> s.equals(toFind)); } private ExecWebSocketListener setupConnectionToPod(URI uri) { diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationContextTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationContextTest.java index 2c1fee1d303..f2cb6af8536 100644 --- a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationContextTest.java +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationContextTest.java @@ -63,6 +63,7 @@ void testCompleteOperationContext() { // When operationContext = operationContext.withNamespace("operation-namespace") .withName("operand-name") + .withSubresource("subresource") .withClient(client) .withApiGroupName("batch") .withApiGroupVersion("v1") @@ -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()); diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupportTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupportTest.java index 2c1d041f1bd..899648f923b 100644 --- a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupportTest.java +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupportTest.java @@ -31,6 +31,7 @@ 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; @@ -126,4 +127,47 @@ 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()).hasToString("https://kubernetes.default.svc/api/v1"); + + OperationSupport pods = new OperationSupport(operationSupport.context.withPlural("pods")); + assertThat(pods.getResourceUrl().toString()).hasToString("https://kubernetes.default.svc/api/v1/pods"); + + pods = new OperationSupport(pods.context.withName("pod-1")); + assertThat(pods.getResourceUrl()).hasToString("https://kubernetes.default.svc/api/v1/pods/pod-1"); + + pods = new OperationSupport(pods.context.withSubresource("ephemeralcontainers")); + assertThat(pods.getResourceUrl()) + .hasToString("https://kubernetes.default.svc/api/v1/pods/pod-1/ephemeralcontainers"); + + pods = new OperationSupport(pods.context.withNamespace("default")); + assertThat(pods.getResourceUrl()) + .hasToString("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/ephemeralcontainers"); + + OperationSupport subresourceWithoutName = new OperationSupport( + operationSupport.context.withPlural("Pods").withSubresource("pod-1")); + assertThrows(KubernetesClientException.class, () -> subresourceWithoutName.getResourceUrl()); + } + + @Test + void getResourceURLStatus() throws MalformedURLException { + OperationSupport pods = new OperationSupport(operationSupport.context.withPlural("pods")); + assertThat(pods.getResourceUrl("default", "pod-1", true)) + .hasToString("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/status"); + assertThat(pods.getResourceUrl("default", "pod-1", false)) + .hasToString("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)) + .hasToString("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/status"); + assertThat(podsSubresource.getResourceUrl("default", "pod-1", false)) + .hasToString("https://kubernetes.default.svc/api/v1/namespaces/default/pods/pod-1/ephemeralcontainers"); + + assertThrows(KubernetesClientException.class, () -> { + operationSupport.getResourceUrl("default", null, true); + }, "status requires name"); + } + } diff --git a/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodEphemeralContainersIT.java b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodEphemeralContainersIT.java new file mode 100644 index 00000000000..684d5ab928b --- /dev/null +++ b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodEphemeralContainersIT.java @@ -0,0 +1,112 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes; + +import io.fabric8.junit.jupiter.api.LoadKubernetesManifests; +import io.fabric8.junit.jupiter.api.RequireK8sVersionAtLeast; +import io.fabric8.kubernetes.api.model.EphemeralContainer; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.ExecWatch; +import io.fabric8.kubernetes.client.dsl.PodResource; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@LoadKubernetesManifests("/pod-it.yml") +@RequireK8sVersionAtLeast(majorVersion = 1, minorVersion = 25) +class PodEphemeralContainersIT { + + KubernetesClient client; + + @Test + void edit() { + Pod pod = client.pods().withName("pod-standard") + .ephemeralContainers() + .edit(p -> new PodBuilder(p) + .editSpec() + .addNewEphemeralContainer() + .withName("debugger-1") + .withImage("alpine") + .withCommand("sh") + .endEphemeralContainer() + .endSpec() + .build()); + + List containers = pod.getSpec().getEphemeralContainers(); + assertTrue(containers.stream().anyMatch(c -> c.getName().equals("debugger-1"))); + } + + @Test + void replace() { + Pod item = client.pods().withName("pod-standard").get(); + Pod replacement = new PodBuilder(item) + .editSpec() + .addNewEphemeralContainer() + .withName("debugger-2") + .withImage("busybox") + .withCommand("sleep", "36000") + .endEphemeralContainer() + .endSpec() + .build(); + + Pod pod = client.pods() + .resource(replacement) + .ephemeralContainers() + .replace(); + + List containers = pod.getSpec().getEphemeralContainers(); + assertTrue(containers.stream().anyMatch(c -> c.getName().equals("debugger-2"))); + } + + @Test + void exec() { + PodResource resource = client.pods().withName("pod-standard"); + resource.ephemeralContainers() + .edit(p -> new PodBuilder(p) + .editSpec() + .addNewEphemeralContainer() + .withName("debugger-3") + .withImage("busybox") + .withCommand("sleep", "36000") + .endEphemeralContainer() + .endSpec() + .build()); + + resource.waitUntilCondition(p -> { + return p.getStatus() + .getEphemeralContainerStatuses() + .stream() + .filter(s -> s.getName().equals("debugger-3")) + .anyMatch(s -> s.getState().getRunning() != null); + }, 2, TimeUnit.MINUTES); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ExecWatch watch = resource.inContainer("debugger-3") + .writingOutput(out) + .exec("sh", "-c", "echo 'hello world!'")) { + assertEquals(0, watch.exitCode().join()); + assertEquals("hello world!\n", out.toString()); + } + } + +} diff --git a/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodIT.java b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodIT.java index dad15e96388..aaa22d8f1b0 100644 --- a/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodIT.java +++ b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PodIT.java @@ -98,7 +98,7 @@ void update() { @Test void delete() { - assertTrue(client.pods().withName("pod-delete").delete().size() == 1); + assertEquals(1, client.pods().withName("pod-delete").delete().size()); } @Test @@ -160,7 +160,7 @@ void execExitCode() throws Exception { .withReadyWaitTimeout(POD_READY_WAIT_IN_MILLIS) .exec("sh", "-c", "echo 'hello world!'"); assertEquals(0, watch.exitCode().join()); - assertNotNull("hello world!", out.toString()); + assertEquals("hello world!\n", out.toString()); } @Test diff --git a/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java b/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java index a00fc7f2930..434ccecf59f 100644 --- a/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java +++ b/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java @@ -40,6 +40,7 @@ import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; import io.fabric8.kubernetes.client.server.mock.OutputStreamMessage; +import io.fabric8.kubernetes.client.server.mock.StatusMessage; import io.fabric8.kubernetes.client.utils.InputStreamPumper; import io.fabric8.kubernetes.client.utils.Utils; import io.fabric8.mockwebserver.internal.WebSocketMessage; @@ -443,7 +444,7 @@ void testAttachWithWritingOutput() throws InterruptedException, IOException { .andEmit(new WebSocketMessage(0L, "\u0002" + expectedError, false, true)) // \u0002 is the file descriptor for stderr .always() .expect("\u0000" + shutdownInput) - .andEmit(new WebSocketMessage(0L, "\u0003shutdown", false, true)) + .andEmit(new StatusMessage(-1)) .always() .done() .always(); @@ -487,9 +488,10 @@ void testAttachWithWritingOutput() throws InterruptedException, IOException { watch.getInput().write(shutdownInput.getBytes(StandardCharsets.UTF_8)); watch.getInput().flush(); - latch.await(1, TimeUnit.MINUTES); + assertTrue(latch.await(1, TimeUnit.MINUTES)); // Then + assertEquals(-1, watch.exitCode().join()); assertEquals(expectedOutput, stdout.toString()); assertEquals(expectedError, stderr.toString()); @@ -913,6 +915,169 @@ void testListFromServer() { assertEquals("True", fromServerPod.getStatus().getConditions().get(0).getStatus()); } + @Test + void testPatchEphemeralContainer() { + PodBuilder podBuilder = new PodBuilder() + .withNewMetadata() + .withNamespace("test") + .withName("pod1") + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("default") + .endContainer() + .endSpec(); + + Pod patchedResponse = podBuilder.editSpec() + .addNewEphemeralContainer() + .withName("debug") + .endEphemeralContainer() + .endSpec() + .withNewStatus() + .addNewEphemeralContainerStatus() + .withName("debug") + .endEphemeralContainerStatus() + .endStatus() + .build(); + + server.expect().withPath("/api/v1/namespaces/test/pods/pod1").andReturn(200, podBuilder.build()).once(); + server.expect().patch().withPath("/api/v1/namespaces/test/pods/pod1/ephemeralcontainers").andReturn(201, patchedResponse) + .once(); + + Pod patched = client.pods().withName("pod1") + .ephemeralContainers() + .edit(pod -> new PodBuilder(pod) + .editSpec() + .addNewEphemeralContainer() + .withName("debug") + .endEphemeralContainer() + .endSpec() + .build()); + + assertEquals(patchedResponse, patched); + } + + @Test + void testExecEphemeralContainer() throws InterruptedException { + PodBuilder podBuilder = new PodBuilder() + .withNewMetadata() + .withNamespace("test") + .withName("pod1") + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("default") + .endContainer() + .addNewEphemeralContainer() + .withName("debug") + .endEphemeralContainer() + .endSpec(); + + String expectedOutput = "file1 file2"; + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1/exec?command=ls&container=debug&stdout=true") + .andUpgradeToWebSocket() + .open(new OutputStreamMessage(expectedOutput)) + .done() + .always(); + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1") + .andReturn(200, podBuilder.build()) + .once(); + + final CountDownLatch execLatch = new CountDownLatch(1); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ExecWatch watch = client.pods() + .withName("pod1") + .inContainer("debug") + .writingOutput(baos) + .usingListener(createCountDownLatchListener(execLatch)) + .exec("ls"); + + execLatch.await(10, TimeUnit.MINUTES); + assertNotNull(watch); + assertEquals(expectedOutput, baos.toString()); + watch.close(); + } + + @Test + void testAttachEphemeralContainer() throws InterruptedException, IOException { + // Given + String validInput = "input"; + String expectedOutput = "output"; + + String invalidInput = "invalid"; + String expectedError = "error"; + + String shutdownInput = "shutdown"; + + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1/attach?container=debug&stdin=true&stdout=true&stderr=true") + .andUpgradeToWebSocket() + .open() + .expect("\u0000" + validInput) // \u0000 is the file descriptor for stdin + .andEmit(new WebSocketMessage(0L, "\u0001" + expectedOutput, false, true)) // \u0001 is the file descriptor for stdout + .always() + .expect("\u0000" + invalidInput) + .andEmit(new WebSocketMessage(0L, "\u0002" + expectedError, false, true)) // \u0002 is the file descriptor for stderr + .always() + .expect("\u0000" + shutdownInput) + .andEmit(new StatusMessage(-1)) + .always() + .done() + .always(); + + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1") + .andReturn(200, + new PodBuilder().withNewMetadata() + .addToAnnotations(PodOperationsImpl.DEFAULT_CONTAINER_ANNOTATION_NAME, "default") + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("first") + .endContainer() + .addNewContainer() + .withName("default") + .endContainer() + .addNewEphemeralContainer() + .withName("debug") + .endEphemeralContainer() + .endSpec() + .build()) + .once(); + + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + CountDownLatch latch = new CountDownLatch(1); + + // When + ExecWatch watch = client.pods() + .withName("pod1") + .inContainer("debug") + .redirectingInput() + .writingOutput(stdout) + .writingError(stderr) + .usingListener(createCountDownLatchListener(latch)) + .attach(); + + watch.getInput().write(validInput.getBytes(StandardCharsets.UTF_8)); + watch.getInput().flush(); + watch.getInput().write(invalidInput.getBytes(StandardCharsets.UTF_8)); + watch.getInput().flush(); + watch.getInput().write(shutdownInput.getBytes(StandardCharsets.UTF_8)); + watch.getInput().flush(); + + // Then + assertTrue(latch.await(1, TimeUnit.MINUTES)); + assertEquals(-1, watch.exitCode().join()); + assertEquals(expectedOutput, stdout.toString()); + assertEquals(expectedError, stderr.toString()); + + watch.close(); + } + private static String portForwardEncode(boolean dataChannel, String str) { try { byte[] data = str.getBytes(StandardCharsets.UTF_8);