Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JENKINS-58975] - Update the working directory to match container working directory #610

Merged
merged 14 commits into from
Dec 13, 2019
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateEncodingException;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -163,6 +164,10 @@ public Cloud getCloud() {
return Jenkins.getInstance().getCloud(getCloudName());
}

public Optional<Pod> getPod() {
return pod == null ? Optional.empty() : Optional.of(pod);
}

/**
* Returns the cloud instance which created this agent.
* @return the cloud instance which created this agent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,24 @@
import java.io.PrintStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import io.fabric8.kubernetes.api.model.Container;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.io.output.TeeOutputStream;
import org.csanchez.jenkins.plugins.kubernetes.ContainerTemplate;
import org.csanchez.jenkins.plugins.kubernetes.KubernetesSlave;
import org.jenkinsci.plugins.workflow.steps.EnvironmentExpander;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
Expand Down Expand Up @@ -246,29 +251,91 @@ public Launcher decorate(final Launcher launcher, final Node node) {
@Override
public Proc launch(ProcStarter starter) throws IOException {
LOGGER.log(Level.FINEST, "Launch proc with environment: {0}", Arrays.toString(starter.envs()));

// find container working dir
KubernetesSlave slave = (KubernetesSlave) node;
FilePath containerWorkingDirFilePath = starter.pwd();
String containerWorkingDirFilePathStr = containerWorkingDirFilePath != null
? containerWorkingDirFilePath.getRemote() : ContainerTemplate.DEFAULT_WORKING_DIR;
String containerWorkingDirStr = ContainerTemplate.DEFAULT_WORKING_DIR;
if (slave != null && slave.getPod().isPresent() && containerName != null) {
Optional<Container> container = slave.getPod().get().getSpec().getContainers().stream()
.filter(container1 -> container1.getName().equals(containerName))
.findAny();
Optional<String> containerWorkingDir = Optional.empty();
if (container.isPresent() && container.get().getWorkingDir() != null) {
containerWorkingDir = Optional.of(container.get().getWorkingDir());
}
if (containerWorkingDir.isPresent()) {
containerWorkingDirStr = containerWorkingDir.get();
}

if (containerWorkingDir.isPresent() && ! containerWorkingDirFilePath.getRemote().startsWith(containerWorkingDirStr)) {
// Container has a custom workingDir, updated the pwd to match container working dir
containerWorkingDirFilePathStr = containerWorkingDirFilePath.getRemote().replaceFirst(
ContainerTemplate.DEFAULT_WORKING_DIR, containerWorkingDirStr);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't work if the jnlp container uses a custom working directory.

Copy link
Contributor Author

@narayanan narayanan Nov 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vlatombe Setting a custom working directory for JNLP container runs into error much before invoking the kubernetes plugin to execute the shell script. Here is my observation

  • Jenkins always cosiders /home/jenkins/agent as the workspace directory for declarative pipelines. This is happening because KubernetesSlave sets remoteFs as default working dir in the following method. When this method gets invoked, YAML declaration of the pod was not yet parsed, so, there is no container so, always sets the default container working dir
    public String getRemoteFs() {
        return getFirstContainer().map(ContainerTemplate::getWorkingDir).orElse(ContainerTemplate.DEFAULT_WORKING_DIR);
    }
  • In the build console output this can be clearly noticed. Following output is printed even when none of the containers use /home/jenkins/agent as working directory, basically this directory will not exist within any of the containers.
    Running on declarative-custom-working-dir-1-0597g-xhtl4-dqklj in /home/jenkins/agent/workspace/declarative Custom Working Dir
  • When executing shell command, Jenkins tries to store the shell commands as a shell script under the remoteFs directory which will be /home/jenkins/agent for all declarative pipelines. But with in the JNLP container, workspace is mounted in a different directory, not in the /home/jenkins/agent. So creation of the shell script in the remote slave fails because that directory does not exist with in JNLP container. This shell script file creation happens with in Jenkins, only after creation of the Shell script control is passed to kubernetes plugin to execute the command.

So, my observation is, for declarative pipelines, JNLP container should always use /home/jenkins/agent as working directory. Declaring a different one will result in error. I think the right solution would be to set the remoteFs to the working directory of JNLP. But I think that involves a bigger refactoring as YAML has to be parsed prior to setting the remoteFs.

I think for now, we can treat JNLP containers not able to use a custom working dir as a limitation. Let me know your thoughts.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

Copy link
Member

@jtnord jtnord Jan 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for now, we can treat JNLP containers not able to use a custom working dir as a limitation. Let me know your thoughts.

They used to be able to - and indeed when upgrading I am no longer able to and my pipelines using kaniko became broken - may or may not have been this change, but changing the workingDir back to the default works (ftr may or may not have been this change - but people with custom jnlp and container images expect to be able to set abitrary working directories) - especially given the field it is still exposed.

containerWorkingDirFilePath = new FilePath(containerWorkingDirFilePath.getChannel(), containerWorkingDirFilePathStr);
LOGGER.log(Level.FINEST, "Modified the pwd to match {0} containers workspace directory : {1}",
new String[]{containerName, containerWorkingDirFilePathStr});
}
}

String[] envVars = starter.envs();
// modify the working dir on envvars part of starter env vars
if (!containerWorkingDirStr.equals(ContainerTemplate.DEFAULT_WORKING_DIR)) {
for (int i = 0; i < envVars.length; i++) {
String keyValue = envVars[i];
String[] split = keyValue.split("=", 2);
if (split[1].startsWith(ContainerTemplate.DEFAULT_WORKING_DIR)) {
// Container has a custom workingDir, update env vars with right workspace folder
split[1] = split[1].replaceFirst(ContainerTemplate.DEFAULT_WORKING_DIR, containerWorkingDirStr);
envVars[i] = split[0] + "=" + split[1];
LOGGER.log(Level.FINEST, "Updated the starter environment variable, key: {0}, Value: {1}",
new String[]{split[0], split[1]});
}
}
}

if (node != null) { // It seems this is possible despite the method javadoc saying it is non-null
final Computer computer = node.toComputer();
if (computer != null) {
List<String> resultEnvVar = new ArrayList<>();
try {
EnvVars environment = computer.getEnvironment();
String[] envs = starter.envs();
for (String keyValue : envs) {
String[] split = keyValue.split("=", 2);
if (!split[1].equals(environment.get(split[0]))) {
// Only keep environment variables that differ from Computer's environment
resultEnvVar.add(keyValue);
if (environment != null) {
Set<String> overriddenKeys = new HashSet<>();
for (String keyValue : envVars) {
String[] split = keyValue.split("=", 2);
if (!split[1].equals(environment.get(split[0]))) {
// Only keep environment variables that differ from Computer's environment
resultEnvVar.add(keyValue);
overriddenKeys.add(split[0]);
}
}

// modify the working dir on envvars part of Computer
if (!containerWorkingDirStr.equals(ContainerTemplate.DEFAULT_WORKING_DIR)) {
for (Map.Entry<String, String> entry : environment.entrySet()) {
if (entry.getValue().startsWith(ContainerTemplate.DEFAULT_WORKING_DIR)
&& !overriddenKeys.contains(entry.getKey())) {
// Value should be overridden and is not overridden earlier
String newValue = entry.getValue().replaceFirst(ContainerTemplate.DEFAULT_WORKING_DIR, containerWorkingDirStr);
String keyValue = entry.getKey() + "=" + newValue;
LOGGER.log(Level.FINEST, "Updated the value for envVar, key: {0}, Value: {1}",
new String[]{entry.getKey(), newValue});
resultEnvVar.add(keyValue);
}
}
}
envVars = resultEnvVar.toArray(new String[resultEnvVar.size()]);
}
envVars = resultEnvVar.toArray(new String[resultEnvVar.size()]);
} catch (InterruptedException e) {
throw new IOException("Unable to retrieve environment variables", e);
}
}
}
return doLaunch(starter.quiet(), envVars, starter.stdout(), starter.pwd(), starter.masks(),
getCommands(starter));
return doLaunch(starter.quiet(), envVars, starter.stdout(), containerWorkingDirFilePath, starter.masks(),
getCommands(starter, containerWorkingDirFilePathStr));
}

private Proc doLaunch(boolean quiet, String[] cmdEnvs, OutputStream outputForCaller, FilePath pwd,
Expand Down Expand Up @@ -534,12 +601,19 @@ private static void doExec(OutputStream stdin, PrintStream out, boolean[] masks,
}
}

static String[] getCommands(Launcher.ProcStarter starter) {
static String[] getCommands(Launcher.ProcStarter starter, String containerWorkingDirStr) {
List<String> allCommands = new ArrayList<String>();

// BourneShellScript.launchWithCookie escapes $ as $$, we convert it to \$
for (String cmd : starter.cmds()) {
allCommands.add(cmd.replaceAll("\\$\\$", "\\\\\\$"));
String fixedCommand = cmd.replaceAll("\\$\\$", "\\\\\\$");
String oldRemoteDir = starter.pwd() != null ? starter.pwd().getRemote() : null;
if (oldRemoteDir != null && ! oldRemoteDir.isEmpty() &&
!oldRemoteDir.equals(containerWorkingDirStr) && fixedCommand.contains(oldRemoteDir)) {
// Container has a custom workingDir, update the dir in commands
fixedCommand = fixedCommand.replaceAll(oldRemoteDir, containerWorkingDirStr);
}
allCommands.add(fixedCommand);
}
return allCommands.toArray(new String[allCommands.size()]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import hudson.EnvVars;
import hudson.model.Computer;
import org.apache.commons.io.output.TeeOutputStream;
import org.apache.commons.lang.RandomStringUtils;
import org.csanchez.jenkins.plugins.kubernetes.KubernetesClientProvider;
Expand Down Expand Up @@ -85,6 +88,7 @@ public class ContainerExecDecoratorTest {

private ContainerExecDecorator decorator;
private Pod pod;
private KubernetesSlave agent;

@Rule
public LoggerRule containerExecLogs = new LoggerRule()
Expand All @@ -108,18 +112,21 @@ public void configureCloud() throws Exception {
String image = "busybox";
Container c = new ContainerBuilder().withName(image).withImagePullPolicy("IfNotPresent").withImage(image)
.withCommand("cat").withTty(true).build();
Container d = new ContainerBuilder().withName(image + "1").withImagePullPolicy("IfNotPresent").withImage(image)
.withCommand("cat").withTty(true).withWorkingDir("/home/jenkins/agent1").build();
String podName = "test-command-execution-" + RandomStringUtils.random(5, "bcdfghjklmnpqrstvwxz0123456789");
pod = client.pods().create(new PodBuilder().withNewMetadata().withName(podName)
.withLabels(getLabels(this, name)).endMetadata().withNewSpec().withContainers(c).endSpec().build());
.withLabels(getLabels(this, name)).endMetadata().withNewSpec().withContainers(c, d).endSpec().build());

System.out.println("Created pod: " + pod.getMetadata().getName());

PodTemplate template = new PodTemplate();
template.setName(pod.getMetadata().getName());
KubernetesSlave agent = mock(KubernetesSlave.class);
agent = mock(KubernetesSlave.class);
when(agent.getNamespace()).thenReturn(client.getNamespace());
when(agent.getPodName()).thenReturn(pod.getMetadata().getName());
when(agent.getKubernetesCloud()).thenReturn(cloud);
doReturn(cloud).when(agent).getKubernetesCloud();
when(agent.getPod()).thenReturn(Optional.of(pod));
StepContext context = mock(StepContext.class);
when(context.get(Node.class)).thenReturn(agent);

Expand Down Expand Up @@ -218,7 +225,8 @@ public void testCommandExecutionWithNohup() throws Exception {
public void commandsEscaping() {
ProcStarter procStarter = new DummyLauncher(null).launch();
procStarter = procStarter.cmds("$$$$", "$$?");
String[] commands = ContainerExecDecorator.getCommands(procStarter);

String[] commands = ContainerExecDecorator.getCommands(procStarter, null);
assertArrayEquals(new String[] { "\\$\\$", "\\$?" }, commands);
}

Expand Down Expand Up @@ -314,14 +322,53 @@ public void testContainerExecPerformance() throws Exception {
}
}

@Test
@Issue("JENKINS-58975")
public void testContainerExecOnCustomWorkingDir() throws Exception {
doReturn(null).when((Node)agent).toComputer();
ProcReturn r = execCommandInContainer("busybox1", agent, false, "env");
assertTrue("Environment variable workingDir1 should be changed to /home/jenkins/agent1",
r.output.contains("workingDir1=/home/jenkins/agent1"));
assertEquals(0, r.exitCode);
assertFalse(r.proc.isAlive());
}

@Test
@Issue("JENKINS-58975")
public void testContainerExecOnCustomWorkingDirWithComputeEnvVars() throws Exception {
EnvVars computeEnvVars = new EnvVars();
computeEnvVars.put("MyDir", "dir");
computeEnvVars.put("MyCustomDir", "/home/jenkins/agent");
Computer computer = mock(Computer.class);
doReturn(computeEnvVars).when(computer).getEnvironment();

doReturn(computer).when((Node)agent).toComputer();
ProcReturn r = execCommandInContainer("busybox1", agent, false, "env");
assertTrue("Environment variable workingDir1 should be changed to /home/jenkins/agent1",
r.output.contains("workingDir1=/home/jenkins/agent1"));
assertTrue("Environment variable MyCustomDir should be changed to /home/jenkins/agent1",
r.output.contains("MyCustomDir=/home/jenkins/agent1"));
assertEquals(0, r.exitCode);
assertFalse(r.proc.isAlive());
}

private ProcReturn execCommand(boolean quiet, String... cmd) throws Exception {
return execCommandInContainer(null, null, quiet, cmd);
}

private ProcReturn execCommandInContainer(String containerName, Node node, boolean quiet, String... cmd) throws Exception {
if (containerName != null && ! containerName.isEmpty()) {
decorator.setContainerName(containerName);
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
Launcher launcher = decorator
.decorate(new DummyLauncher(new StreamTaskListener(new TeeOutputStream(out, System.out))), null);
.decorate(new DummyLauncher(new StreamTaskListener(new TeeOutputStream(out, System.out))), node);
Map<String, String> envs = new HashMap<>(100);
for (int i = 0; i < 50; i++) {
envs.put("aaaaaaaa" + i, "bbbbbbbb");
}
envs.put("workingDir1", "/home/jenkins/agent");

ContainerExecProc proc = (ContainerExecProc) launcher
.launch(launcher.new ProcStarter().pwd("/tmp").cmds(cmd).envs(envs).quiet(quiet));
// wait for proc to finish (shouldn't take long)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ public void declarativeCustomWorkspace() throws Exception {
r.assertLogContains("Workspace dir is", b);
}

@Issue("JENKINS-58975")
@Test
public void declarativeCustomWorkingDir() throws Exception {
assertNotNull(createJobThenScheduleRun());
r.assertBuildStatusSuccess(r.waitForCompletion(b));
r.assertLogContains("[jnlp] current dir is /home/jenkins/agent/workspace/declarative Custom Working Dir/foo", b);
r.assertLogContains("[jnlp] WORKSPACE=/home/jenkins/agent/workspace/declarative Custom Working Dir", b);
r.assertLogContains("[maven] current dir is /home/jenkins/wsp1/workspace/declarative Custom Working Dir/foo", b);
r.assertLogContains("[maven] WORKSPACE=/home/jenkins/wsp1/workspace/declarative Custom Working Dir", b);
r.assertLogContains("[default:maven] current dir is /home/jenkins/wsp1/workspace/declarative Custom Working Dir/foo", b);
r.assertLogContains("[default:maven] WORKSPACE=/home/jenkins/wsp1/workspace/declarative Custom Working Dir", b);
}

@Issue("JENKINS-57548")
@Test
public void declarativeWithNestedExplicitInheritance() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mock-maker-inline
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
pipeline {
agent {
kubernetes {
defaultContainer 'maven'
yaml """
metadata:
labels:
some-label: some-label-value
class: KubernetesDeclarativeAgentTest
spec:
containers:
- name: jnlp
env:
- name: CONTAINER_ENV_VAR
value: jnlp
- name: maven
image: maven:3.3.9-jdk-8-alpine
workingDir: /home/jenkins/wsp1
command:
- cat
tty: true
env:
- name: CONTAINER_ENV_VAR
value: maven
"""
}
}

stages {
stage('Run maven') {
steps {
dir('foo') {
container('jnlp') {
sh 'echo [jnlp] current dir is $(pwd)'
sh 'echo [jnlp] WORKSPACE=$WORKSPACE'
}
container('maven') {
sh 'mvn -version'
sh 'echo [maven] current dir is $(pwd)'
sh 'echo [maven] WORKSPACE=$WORKSPACE'
}
sh 'echo [default:maven] current dir is $(pwd)'
sh 'echo [default:maven] WORKSPACE=$WORKSPACE'
}
}
}
}
}