Skip to content
Draft
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2170791
Sketch of `ContainerListenDecorator`
jglick May 20, 2026
a257bd1
SpotBugs
jglick May 21, 2026
7130253
NPE in `PodTemplateBuilderTest`
jglick May 21, 2026
d4706f2
At least basic handling of env vars
jglick May 21, 2026
f85935f
Properly quote `LOC`
jglick May 21, 2026
79de706
`test` rather than `-f` maybe clearer
jglick May 21, 2026
455e199
Use container env for clarity
jglick May 21, 2026
b58c5c9
Preparing a bootstrap system
jglick May 21, 2026
ab2b091
Filter environment variables in ContainerListenDecorator
jglick May 21, 2026
4060416
Stylistic cleanups
jglick May 21, 2026
95b67da
Easier to read multiline script
jglick May 21, 2026
368dc88
Noting constraints on shell script
jglick May 21, 2026
d6d37b0
stdio (and exit code) working
jglick May 21, 2026
e8f98d3
Clean up the proc dir when we are done with it
jglick May 21, 2026
9370a27
Suppressing irrelevant test
jglick May 21, 2026
05f48d7
`KubernetesDeclarativeAgentTest.declarativeFromYamlWithNullEnv` was g…
jglick May 21, 2026
e178652
`RestartPipelineTest.terminatedPodAfterRestart` passed though decorat…
jglick May 21, 2026
27244b7
Containers may use a different mount path for the workspace, or have …
jglick May 22, 2026
ea5a7c0
Quieter bootstrap script
jglick May 22, 2026
0455475
Always `undecorate` the launcher, in case `container` step is used in…
jglick May 22, 2026
741b23e
Silly result of IDE convert anon inner to nested
jglick May 22, 2026
e444656
Simpler way of finding `Pod`
jglick May 22, 2026
f8613b0
Noting to Claude how to run tests
jglick May 26, 2026
4b496c7
`ContainerExecDecoratorPipelineTest.docker` asserts `maskedPrintComma…
jglick May 26, 2026
07ad3c6
`KubernetesDeclarativeAgentTest.declarativeWithNestedExplicitInherita…
jglick May 26, 2026
6a6bbc0
Merge branch 'master' of https://github.com/jenkinsci/kubernetes-plug…
jglick May 28, 2026
93b304a
`KubernetesPipelineTest#runWithEnvVariablesInContext` covers `BASH_CO…
jglick May 28, 2026
41e46f7
`KubernetesPipelineTest#runInPod` as of #244 was getting confused by …
jglick May 28, 2026
9d17d01
`KubernetesPipelineTest#interruptedPod` fixed by implementing `Launch…
jglick May 28, 2026
25278c5
Pick up https://github.com/jenkinsci/jenkins-test-harness/pull/1175
jglick May 28, 2026
ee5a90b
Straightforward enough to handle `ContainerStep.shell` correctly, but…
jglick May 29, 2026
964ba3f
https://github.com/jenkinsci/durable-task-plugin/pull/561 released
jglick Jul 7, 2026
b2575f5
Switch from system property to cloud flag
jglick Jul 7, 2026
467ee14
Gratuitous modifiers
jglick Jul 7, 2026
6af12ad
Merge branch 'ltsc2022' into ContainerStepExecution
jglick Jul 8, 2026
517182b
Politely falling back to original impl on Windows
jglick Jul 8, 2026
4b467c3
Noting new mode in README
jglick Jul 8, 2026
f238341
Merge branch 'master' into ContainerStepExecution
jglick Jul 9, 2026
6d61b6a
Merge branch 'ltsc2022' into ContainerStepExecution
jglick Jul 9, 2026
d8cf4d7
Merge branch 'master' into ContainerStepExecution
jglick Jul 13, 2026
3ca3f27
Revert "Straightforward enough to handle `ContainerStep.shell` correc…
jglick Jul 13, 2026
2a9b72c
Disable by default; introduce dedicated test class; tone down logging
jglick Jul 13, 2026
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
All tests in this plugin which touch an actual Kubernetes cluster (mainly those extending @src/test/java/org/csanchez/jenkins/plugins/kubernetes/pipeline/AbstractKubernetesPipelineTest.java) require you to pass `-Pktunnel` to Maven.
Also use `-DforkCount=1` when running multiple cluster-based test classes at once to disable concurrency, since the tests use fixed port numbers.
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public class PodTemplateBuilder {

private static final Logger LOGGER = Logger.getLogger(PodTemplateBuilder.class.getName());

private static final String WORKSPACE_VOLUME_NAME = "workspace-volume";
public static final String WORKSPACE_VOLUME_NAME = "workspace-volume";
public static final Pattern FROM_DIRECTIVE = Pattern.compile("^FROM (.*)$");

public static final String LABEL_KUBERNETES_CONTROLLER = "kubernetes.jenkins.io/controller";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
/*
* Copyright 2026 CloudBees, 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 org.csanchez.jenkins.plugins.kubernetes.pipeline;

import hudson.AbortException;
import hudson.CloseProofOutputStream;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.LauncherDecorator;
import hudson.Proc;
import hudson.model.Node;
import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.VolumeMount;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.random.RandomGenerator;
import java.util.stream.Stream;
import jenkins.util.SystemProperties;
import org.csanchez.jenkins.plugins.kubernetes.KubernetesCloud;
import org.csanchez.jenkins.plugins.kubernetes.KubernetesSlave;
import org.csanchez.jenkins.plugins.kubernetes.PodTemplateBuilder;
import org.csanchez.jenkins.plugins.kubernetes.pod.decorator.PodDecorator;

/**
* Alternative to {@link ContainerExecDecorator} which does not rely on the API server.
* “Sleepy” containers are automatically switched to listen for commands from the agent container.
*/
final class ContainerListenDecorator extends LauncherDecorator implements Serializable, Closeable {

static final boolean ENABLED =
SystemProperties.getBoolean(ContainerListenDecorator.class.getName() + ".enabled", true);

private static final Logger LOGGER = Logger.getLogger(ContainerListenDecorator.class.getName());

@Serial
private static final long serialVersionUID = 1;

private final String container;

ContainerListenDecorator(String container) {
this.container = container;
}

@Override
public Launcher decorate(Launcher launcher, Node node) {
if (!(node instanceof KubernetesSlave ks)) {
LOGGER.warning(() -> "container step used on unexpected node " + node);
return launcher;
}
var podO = ks.getPod();
if (!podO.isPresent()) {
LOGGER.warning(() -> "container step used " + node + " but Pod could not be looked up");
return launcher;
}
var pod = podO.get();
launcher = undecorate(launcher);
try {
var c = pod.getSpec().getContainers().stream()
.filter(_c -> container.equals(_c.getName()))
.findFirst()
.orElse(null);
if (c != null) {
if (c.getEnv() == null
|| !c.getEnv().stream().anyMatch(e -> e.getName().equals("JENKINS_CONTAINER_NAME"))) {
launcher.getListener()
.getLogger()
.println("Warning: container step on unpatched container " + c.getName());
return launcher;
}
} else {
LOGGER.warning(() -> "Could not find container " + container + " in " + ks.getPodName());
}
} catch (Exception x) {
LOGGER.log(
Level.WARNING,
x,
() -> "Could not verify eligibility of container " + container + " in " + ks.getPodName());
}
LOGGER.info("TODO prepping launcher for " + container);
return new LauncherImpl(launcher, ks, pod);
}

private static final Launcher undecorate(Launcher launcher) {
if (launcher instanceof LauncherImpl decorated) {
return undecorate(decorated.getInner());
} else {
return launcher;
}
}

@Override
public void close() throws IOException {
// anything to do?
}

private class LauncherImpl extends Launcher.DecoratedLauncher {
private final KubernetesSlave ks;
private final Pod pod;

public LauncherImpl(Launcher base, KubernetesSlave ks, Pod pod) {
super(base);
this.ks = ks;
this.pod = pod;
}

@Override
public Proc launch(Launcher.ProcStarter starter) throws IOException {
if (!isUnix()) {
throw new AbortException("TODO Windows not yet supported");
}
FilePath procDir;
try {
var workspaceToAgent = workspaceVolumeMountPath(pod, c -> c.getEnv().stream()
.anyMatch(e -> e.getName().equals("JENKINS_AGENT_WORKDIR")))
.findFirst()
.orElseThrow(() -> new IOException("Cannot find JENKINS_AGENT_WORKDIR in " + ks.getPodName()));
var work = new FilePath(getChannel(), workspaceToAgent + "/container-work");
var bootstrap = work.child("bootstrap.sh");
if (!bootstrap.exists()) {
work.mkdirs();
bootstrap.copyFrom(ContainerListenDecorator.class.getResource("scripts/container-bootstrap.sh"));
LOGGER.info("TODO created " + bootstrap);
}
procDir = work.child(container)
.child("%016x".formatted(RandomGenerator.getDefault().nextLong()));
procDir.mkdirs();
var f = procDir.child("script.sh");
var sb = new StringBuilder();
var pwdF = starter.pwd();
var pwd = pwdF != null ? pwdF.getRemote() : null;
var envVars = starter.envs();
var kc = ks.toComputer();
if (kc != null) {
// Remove env vars which are simply inherited from the agent container’s environment.
// Some of these like $JAVA_HOME could be unnecessary or even harmful in other containers.
var agentEnv = kc.getEnvironment();
envVars = Stream.of(envVars)
.filter(kv -> {
var split = kv.split("=", 2);
return split.length == 2 && !split[1].equals(agentEnv.get(split[0]));
})
.toArray(String[]::new);
}
var cmds = starter.cmds();
var workspaceToContainer = workspaceVolumeMountPath(
pod, c -> c.getName().equals(container))
.findFirst()
.orElse(null);
if (workspaceToContainer != null && !workspaceToContainer.equals(workspaceToAgent)) {
LOGGER.info("TODO translating " + workspaceToAgent + " to " + workspaceToContainer);
if (pwd.startsWith(workspaceToAgent + "/")) {
pwd = workspaceToContainer + pwd.substring(workspaceToAgent.length());
}
// e.g., various paths created by BourneShellScript wrapper:
cmds = cmds.stream()
.map(s -> s.replaceAll(workspaceToAgent, workspaceToContainer))
.toList();
// $WORKSPACE, $WORKSPACE_TMP
envVars = Stream.of(envVars)
.map(s -> s.replaceAll(workspaceToAgent, workspaceToContainer))
.toArray(String[]::new);
}
if (pwd != null) {
sb.append("cd ");
quote(sb, pwd);
sb.append("\n");
}
for (var env : envVars) {
sb.append("export ");
quote(sb, env);
sb.append("\n");
}
for (var cmd : cmds) {
cmd = cmd.replace("$$", "$"); // undo BourneShellScript.scriptLauncherCmd
quote(sb, cmd);
sb.append(" ");
}
f.write(sb.toString(), null);
LOGGER.info("TODO wrote to " + f + ": \n" + sb);
if (!starter.quiet()) {
maskedPrintCommandLine(
cmds, starter.masks(), pwd != null ? new FilePath(pwdF.getChannel(), pwd) : null);
}
} catch (InterruptedException x) {
throw new IOException(x);
}
return new Proc() {
@Override
public boolean isAlive() throws IOException, InterruptedException {
return procDir.child("seen").exists()
&& !procDir.child("status.txt").exists();
}

@Override
public void kill() throws IOException, InterruptedException {
// TODO could touch some death flag
procDir.child("status.txt").write("-1", null);
}

@Override
public int join() throws IOException, InterruptedException {
var status = procDir.child("status.txt");
LOGGER.info("TODO waiting for " + status);
while (!status.exists()) {
Thread.sleep(100);
}
// No streaming supported, just logging/capturing of stdio from a completed process.
// In practice most steps are durable, which produce no stdio themselves;
// or relatively brief and so streaming is unnecessary.
LOGGER.info("TODO " + procDir + " completed with status "
+ status.readToString().trim() + " and stdout "
+ procDir.child("out.txt").length() + "b stderr"
+ procDir.child("err.txt").length() + "b");
var os = starter.stdout();
if (os == null) {
os = getListener().getLogger();
}
procDir.child("out.txt").copyTo(new CloseProofOutputStream(os));
os = starter.stderr();
if (os == null) {
os = getListener().getLogger();
}
procDir.child("err.txt").copyTo(new CloseProofOutputStream(os));
var r = Integer.parseInt(status.readToString().trim());
procDir.deleteRecursive();
return r;
}

@Override
public InputStream getStdout() {
return null;
}

@Override
public InputStream getStderr() {
return null;
}

@Override
public OutputStream getStdin() {
return OutputStream.nullOutputStream();
}
};
}
}

private static void quote(StringBuilder sb, String s) {
sb.append("'");
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\'') {
sb.append("'\"'\"'"); // or '\''
} else {
sb.append(c);
}
}
sb.append("'");
}

private static Stream<String> workspaceVolumeMountPath(Container container) {
return container.getVolumeMounts().stream()
.filter(vm -> vm.getName().equals(PodTemplateBuilder.WORKSPACE_VOLUME_NAME))
.map(VolumeMount::getMountPath);
}

private static Stream<String> workspaceVolumeMountPath(Pod pod, Predicate<Container> container) {
return pod.getSpec().getContainers().stream()
.flatMap(c -> container.test(c) ? workspaceVolumeMountPath(c) : Stream.of());
}

@Extension
public static final class Decorator implements PodDecorator {

@Override
public Pod decorate(KubernetesCloud kubernetesCloud, Pod pod) {
if (ENABLED) {
// TODO exclude Windows
for (var c : pod.getSpec().getContainers()) {
var cmds = c.getCommand();
if (cmds != null && !cmds.isEmpty() && cmds.get(0).matches("((/usr)?/bin/)?(sleep|cat)")) {
var name = c.getName();
var workspace = workspaceVolumeMountPath(c).findFirst().orElse(null);
if (workspace == null) {
LOGGER.warning(() -> "Cannot find " + PodTemplateBuilder.WORKSPACE_VOLUME_NAME + " in "
+ name + " in " + pod.getMetadata().getName());
continue;
}
c.setCommand(List.of("sh"));
c.setArgs(
List.of(
"-c",
"until test -f \"$JENKINS_CONTAINER_WORK\"/bootstrap.sh; do sleep 1; done; . \"$JENKINS_CONTAINER_WORK\"/bootstrap.sh"));
var env = new ArrayList<>(c.getEnv() != null ? c.getEnv() : List.of());
env.add(new EnvVar("JENKINS_CONTAINER_NAME", name, null));
env.add(new EnvVar("JENKINS_CONTAINER_WORK", workspace + "/container-work", null));
c.setEnv(env);
LOGGER.info(() -> "adjusted container " + name + " in "
+ pod.getMetadata().getName());
} else {
LOGGER.fine(() -> c + " does not match");
}
}
}
return pod;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class ContainerStepExecution extends StepExecution {
private final transient ContainerStep step;

private ContainerExecDecorator decorator;
private ContainerListenDecorator listenDecorator;

ContainerStepExecution(ContainerStep step, StepContext context) {
super(context);
Expand Down Expand Up @@ -70,26 +71,34 @@ public boolean start() throws Exception {
rcEnvVars = run.getEnvironment(taskListener);
}

decorator = new ContainerExecDecorator();
decorator.setNodeContext(nodeContext);
decorator.setContainerName(containerName);
decorator.setEnvironmentExpander(env);
decorator.setGlobalVars(globalVars);
decorator.setRunContextEnvVars(rcEnvVars);
decorator.setShell(shell);
if (ContainerListenDecorator.ENABLED) {
listenDecorator = new ContainerListenDecorator(containerName);
// TODO handle globalVars, rcEnvVars, shell if needed (basic env filtering is implemented)
} else {
decorator = new ContainerExecDecorator();
decorator.setNodeContext(nodeContext);
decorator.setContainerName(containerName);
decorator.setEnvironmentExpander(env);
decorator.setGlobalVars(globalVars);
decorator.setRunContextEnvVars(rcEnvVars);
decorator.setShell(shell);
}
getContext()
.newBodyInvoker()
.withContexts(
BodyInvoker.mergeLauncherDecorators(getContext().get(LauncherDecorator.class), decorator), env)
.withCallback(closeQuietlyCallback(decorator))
BodyInvoker.mergeLauncherDecorators(
getContext().get(LauncherDecorator.class),
listenDecorator != null ? listenDecorator : decorator),
env)
.withCallback(closeQuietlyCallback(listenDecorator != null ? listenDecorator : decorator))
.start();
return false;
}

@Override
public void stop(@NonNull Throwable cause) throws Exception {
LOGGER.log(Level.FINE, "Stopping container step.");
closeQuietly(getContext(), decorator);
closeQuietly(getContext(), listenDecorator != null ? listenDecorator : decorator);
}

/**
Expand Down
Loading
Loading