From caee8db407bd31e25862bba77769d429163fc2c1 Mon Sep 17 00:00:00 2001 From: Pavel Busko Date: Mon, 18 Sep 2023 13:05:01 +0200 Subject: [PATCH] feat(dockerExecute): Infer Kubernetes securityContext from dockerOptions (#4557) * Allow running as different user on Kubernetes Co-authored-by: Ralf Pannemans Co-authored-by: Johannes Dillmann Co-authored-by: Pavel Busko * infer securityContext from dockerOptions Co-authored-by: Ralf Pannemans Co-authored-by: Pavel Busko * verify --user flag value --------- Co-authored-by: Johannes Dillmann Co-authored-by: Ralf Pannemans Co-authored-by: Anil Keshav --- test/groovy/DockerExecuteTest.groovy | 95 ++++++++++++++++++++++++++++ vars/dockerExecute.groovy | 66 ++++++++++++++++--- 2 files changed, 151 insertions(+), 10 deletions(-) diff --git a/test/groovy/DockerExecuteTest.groovy b/test/groovy/DockerExecuteTest.groovy index 647a09e4e2..d452274df2 100644 --- a/test/groovy/DockerExecuteTest.groovy +++ b/test/groovy/DockerExecuteTest.groovy @@ -143,6 +143,101 @@ class DockerExecuteTest extends BasePiperTest { assertTrue(bodyExecuted) } + @Test + void testExecuteInsidePodWithCustomUserShort() throws Exception { + Map kubernetesConfig = [:] + helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> + kubernetesConfig = config + return body() + }) + binding.setVariable('env', [ON_K8S: 'true']) + stepRule.step.dockerExecute( + script: nullScript, + dockerImage: 'maven:3.5-jdk-8-alpine', + dockerOptions: ["-u 0:0", "-v foo:bar"] + ) { + bodyExecuted = true + } + + assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Pod')) + assertThat(kubernetesConfig.securityContext, is([ + 'runAsUser': 0, + 'runAsGroup': 0 + ])) + assertTrue(bodyExecuted) + } + + @Test + void testExecuteInsidePodWithCustomUserLong() throws Exception { + Map kubernetesConfig = [:] + helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> + kubernetesConfig = config + return body() + }) + binding.setVariable('env', [ON_K8S: 'true']) + stepRule.step.dockerExecute( + script: nullScript, + dockerImage: 'maven:3.5-jdk-8-alpine', + dockerOptions: ["--user 0:0", "-v foo:bar"] + ) { + bodyExecuted = true + } + + assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Pod')) + assertThat(kubernetesConfig.securityContext, is([ + 'runAsUser': 0, + 'runAsGroup': 0 + ])) + assertTrue(bodyExecuted) + } + + @Test + void testExecuteInsidePodWithCustomUserNoGroup() throws Exception { + Map kubernetesConfig = [:] + helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> + kubernetesConfig = config + return body() + }) + binding.setVariable('env', [ON_K8S: 'true']) + stepRule.step.dockerExecute( + script: nullScript, + dockerImage: 'maven:3.5-jdk-8-alpine', + dockerOptions: ["-v foo:bar", "-u 0"] + ) { + bodyExecuted = true + } + + assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Pod')) + assertThat(kubernetesConfig.securityContext, is([ + 'runAsUser': 0 + ])) + assertTrue(bodyExecuted) + } + + @Test + void testExecuteInsidePodWithCustomUserGroupString() throws Exception { + Map kubernetesConfig = [:] + helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> + kubernetesConfig = config + return body() + }) + binding.setVariable('env', [ON_K8S: 'true']) + stepRule.step.dockerExecute( + script: nullScript, + dockerImage: 'maven:3.5-jdk-8-alpine', + dockerOptions: ["-v foo:bar", "-u root:wheel"] + ) { + bodyExecuted = true + } + + assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Pod')) + assertThat(kubernetesConfig.securityContext, is([ + 'runAsUser': 'root', + 'runAsGroup': 'wheel' + ])) + assertTrue(bodyExecuted) + } + @Test void testExecuteInsideDockerContainer() throws Exception { stepRule.step.dockerExecute(script: nullScript, dockerImage: 'maven:3.5-jdk-8-alpine') { diff --git a/vars/dockerExecute.groovy b/vars/dockerExecute.groovy index 6113975c09..1d7a2e9407 100644 --- a/vars/dockerExecute.groovy +++ b/vars/dockerExecute.groovy @@ -181,6 +181,8 @@ void call(Map parameters = [:], body) { config.dockerEnvVars?.each { key, value -> dockerEnvVars << "$key=$value" } + + def securityContext = securityContextFromOptions(config.dockerOptions) if (env.POD_NAME && isContainerDefined(config)) { container(getContainerDefined(config)) { withEnv(dockerEnvVars) { @@ -205,6 +207,7 @@ void call(Map parameters = [:], body) { dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent, stashNoDefaultExcludes: config.stashNoDefaultExcludes, + securityContext: securityContext, ] if (config.sidecarImage) { @@ -217,6 +220,7 @@ void call(Map parameters = [:], body) { sidecarEnvVars: parameters.sidecarEnvVars, ] } + dockerExecuteOnKubernetes(dockerExecuteOnKubernetesParams) { echo "[INFO][${STEP_NAME}] Executing inside a Kubernetes Pod" body() @@ -340,20 +344,40 @@ private getDockerOptions(Map dockerEnvVars, Map dockerVolumeBind, def dockerOpti } if (dockerOptions) { - if (dockerOptions instanceof CharSequence) { - dockerOptions = [dockerOptions] - } - if (dockerOptions instanceof List) { - dockerOptions.each { String option -> - options << escapeBlanks(option) - } - } else { - throw new IllegalArgumentException("Unexpected type for dockerOptions. Expected was either a list or a string. Actual type was: '${dockerOptions.getClass()}'") - } + options.addAll(dockerOptionsToList(dockerOptions)) } + return options.join(' ') } +@NonCPS +def securityContextFromOptions(dockerOptions) { + Map securityContext = [:] + + if (!dockerOptions) { + return null + } + + def userOption = dockerOptionsToList(dockerOptions).find { (it.startsWith("-u ") || it.startsWith("--user ")) } + if (!userOption) { + return null + } + + def userOptionParts = userOption.split(" ") + if (userOptionParts.size() != 2) { + throw new IllegalArgumentException("Unexpected --user flag value in dockerOptions '${userOption}'") + } + + def userGroupIds = userOptionParts[1].split(":") + + securityContext.runAsUser = userGroupIds[0].isInteger() ? userGroupIds[0].toInteger() : userGroupIds[0] + + if (userGroupIds.size() == 2) { + securityContext.runAsGroup = userGroupIds[1].isInteger() ? userGroupIds[1].toInteger() : userGroupIds[1] + } + + return securityContext +} boolean isContainerDefined(config) { Map containerMap = ContainerMap.instance.getMap() @@ -383,6 +407,28 @@ boolean isKubernetes() { return Boolean.valueOf(env.ON_K8S) } +@NonCPS +def dockerOptionsToList(dockerOptions) { + def options = [] + if (!dockerOptions) { + return options + } + + if (dockerOptions instanceof CharSequence) { + dockerOptions = [dockerOptions] + } + + if (dockerOptions instanceof List) { + dockerOptions.each { String option -> + options << escapeBlanks(option) + } + } else { + throw new IllegalArgumentException("Unexpected type for dockerOptions. Expected was either a list or a string. Actual type was: '${dockerOptions.getClass()}'") + } + + return options +} + /* * Escapes blanks for values in key/value pairs * E.g. description=Lorem ipsum is