Skip to content

Commit

Permalink
#7 Implements Docker.Image.mountJenkinsUser
Browse files Browse the repository at this point in the history
  • Loading branch information
schnatterer committed Dec 11, 2017
1 parent b3e8ec5 commit e7a187c
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 36 deletions.
111 changes: 94 additions & 17 deletions src/com/cloudogu/ces/cesbuildlib/Docker.groovy
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package com.cloudogu.ces.cesbuildlib

/**
* Basic abstraction for docker.
*
Expand Down Expand Up @@ -59,7 +58,7 @@ class Docker implements Serializable {
* Example:
* <pre>
* def dockerImage = docker.build("image/name:1.0", "folderOfDockfile")
* docker.withRegistry("https://your.registry", 'credentialsId') {* dockerImage.push()
* docker.withRegistry("https://your.registry", 'credentialsId') {* dockerimage().push()
*}* </pre>
*/
def withRegistry(String url, String credentialsId = null, Closure body) {
Expand Down Expand Up @@ -113,17 +112,45 @@ class Docker implements Serializable {

private final script
private image
/** The image name with optional tag (mycorp/myapp, mycorp/myapp:latest) or ID (hexadecimal hash). **/
String id
// Don't mix this up with Jenkins docker.image.id, which is an ImageNameTokens object
// See getId()
private String imageIdString
Sh sh

private Image(script, String id) {
/**
* Provides the user that executes the Build within docker container.
* This is necessary for some commands such as npm.
*
* Creates {@code passwd} file that is mounted into a container started from this image().
* This {@code passwd} file contains the username, UID, GID of the user that executes the build and also sets
* the current workspace as HOME within the docker container.
*
* @return an instance of this image, for a fluent API.
*/
boolean mountJenkinsUser = false

Image(script, String id) {
imageIdString = id
this.script = script
image = script.docker.image(id)
this.id = image.id
this.sh = new Sh(script)
}

// Creates an image instance. Can't be called from constructor because of CpsCallableInvocation
// See https://issues.jenkins-ci.org/browse/JENKINS-26313
private def image() {
if (!image) {
image = script.docker.image(imageIdString)
}
return image
}

String imageName() {
return image.imageName()
return image().imageName()
}

/** The image name with optional tag (mycorp/myapp, mycorp/myapp:latest) or ID (hexadecimal hash). **/
String getId() {
return image().id
}

/**
Expand All @@ -132,7 +159,8 @@ class Docker implements Serializable {
* directory (normally a Jenkins agent workspace), which means that the Docker server must be on localhost.
*/
def inside(String args = '', Closure body) {
return image.inside(args, body)
def extendedArgs = extendArgs(args)
return image().inside(extendedArgs, body)
}

/**
Expand All @@ -145,33 +173,82 @@ class Docker implements Serializable {
/**
* Uses docker run to run the image, and returns a Container which you could stop later. Additional args may
* be added, such as '-p 8080:8080 --memory-swap=-1'. Optional command is equivalent to Docker command
* specified after the image. Records a run fingerprint in the build.
* specified after the image(). Records a run fingerprint in the build.
*/
def run(String args = '', String command = "") {
return image.run(args, command)
def extendedArgs = extendArgs(args)
return image().run(extendedArgs, command)
}

/**
* Like run but stops the container as soon as its body exits, so you do not need a try-finally block.
*/
def withRun(String args = '', String command = "", Closure body) {
return image.withRun(args, command, body)
def extendedArgs = extendArgs(args)
return image().withRun(extendedArgs, command, body)
}

/**
* Runs docker tag to record a tag of this image (defaulting to the tag it already has). Will rewrite an
* existing tag if one exists.
*/
void tag(String tagName = image.parsedId.tag, boolean force = true) {
image.tag(tagName, force)
void tag(String tagName = image().parsedId.tag, boolean force = true) {
image().tag(tagName, force)
}

/**
* Pushes an image to the registry after tagging it as with the tag method. For example, you can use image.push
* Pushes an image to the registry after tagging it as with the tag method. For example, you can use image().push
* 'latest' to publish it as the latest version in its repository.
*/
void push(String tagName = image.parsedId.tag, boolean force = true) {
image.push(tagName, force)
void push(String tagName = image().parsedId.tag, boolean force = true) {
image().push(tagName, force)
}

private extendArgs(String args) {
String extendedArgs = args
if (mountJenkinsUser) {
String passwdPath = writePasswd()
extendedArgs += " -v ${script.pwd()}/${passwdPath}:/etc/passwd:ro"
}
return extendedArgs
}

String writePasswd() {
def passwdPath = '.jenkins/passwd'

// e.g. "jenkins:x:1000:1000::/home/jenkins:/bin/sh"
String passwd = readJenkinsUserFromEtcPasswdCutOffAfterGroupId() + ":${script.pwd()}:/bin/sh"

script.writeFile file: passwdPath, text: passwd
return passwdPath
}

/**
* Return from /etc/passwd (for user that executes build) only username, pw, UID and GID.
* e.g. "jenkins:x:1000:1000:"
*/
private String readJenkinsUserFromEtcPasswdCutOffAfterGroupId() {
def regexMatchesUntilFourthColon = '(.*?:){4}'

def etcPasswd = readJenkinsUserFromEtcPasswd()

// Storing matcher in a variable might lead to java.io.NotSerializableException: java.util.regex.Matcher
if (!(etcPasswd =~ regexMatchesUntilFourthColon)) {
script.error '/etc/passwd entry for current user does not match user:x:uid:gid:'
}
return (etcPasswd =~ regexMatchesUntilFourthColon)[0][0]
}

private String readJenkinsUserFromEtcPasswd() {
// Query current jenkins user string, e.g. "jenkins:x:1000:1000:Jenkins,,,:/home/jenkins:/bin/bash"
// An alternative (dirtier) approach: https://github.com/cloudogu/docker-golang/blob/master/Dockerfile
// TODO use System.properties. 'user.name' instead of jenkins (#6)
String jenkinsUserFromEtcPasswd = sh.returnStdOut 'cat /etc/passwd | grep jenkins'

if (jenkinsUserFromEtcPasswd.isEmpty()) {
script.error 'Unable to parse user jenkins from /etc/passwd.'
}
return jenkinsUserFromEtcPasswd
}
}
}
2 changes: 1 addition & 1 deletion src/com/cloudogu/ces/cesbuildlib/Sh.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Sh implements Serializable {
}

/**
* @return the trimmed stdout of the shell call
* @return the trimmed stdout of the shell call. Most likeley never {@code null}
*/
String returnStdOut(args) {
return script.sh(returnStdout: true, script: args)
Expand Down
115 changes: 97 additions & 18 deletions test/com/cloudogu/ces/cesbuildlib/DockerTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ package com.cloudogu.ces.cesbuildlib

import org.junit.Test

import static groovy.test.GroovyAssert.shouldFail
import static org.junit.Assert.*

class DockerTest {

def expectedImage = 'google/cloud-sdk:164.0.0'
def expectedHome = '/home/jenkins'
def actualPasswd = 'jenkins:x:1000:1000:Jenkins,,,:/home/jenkins:/bin/bash'
Map<String, String> actualWriteFileArgs = [:]

@Test
void findIp() {
String containerId = '93a401b14684'
Expand All @@ -18,8 +24,8 @@ class DockerTest {
void findEnv() {
String containerId = '93a401b14684'
Docker docker = new Docker([sh: { Map<String, String> args -> return args['script'] }])
def evn = docker.findEnv([id: containerId])
assertTrue(evn.contains(containerId))
def env = docker.findEnv([id: containerId])
assertTrue(env.contains(containerId))
}

@Test
Expand Down Expand Up @@ -129,9 +135,7 @@ class DockerTest {

@Test
void imageInside() {
def expectedImage = 'google/cloud-sdk:164.0.0'

Docker docker = createWithImage(expectedImage,
Docker docker = createWithImage(
[inside: { String param1, Closure param2 ->
return [param1, param2]
}])
Expand All @@ -144,9 +148,7 @@ class DockerTest {

@Test
void imageId() {
def expectedImage = 'google/cloud-sdk:164.0.0'

Docker docker = createWithImage(expectedImage, [imageName: { expectedImage }])
Docker docker = createWithImage([imageName: { expectedImage }])

def args = docker.image(expectedImage).id

Expand All @@ -155,9 +157,7 @@ class DockerTest {

@Test
void imageName() {
def expectedImage = 'google/cloud-sdk:164.0.0'

Docker docker = createWithImage(expectedImage, [imageName: { expectedImage }])
Docker docker = createWithImage([imageName: { expectedImage }])

def args = docker.image(expectedImage).imageName()

Expand All @@ -166,9 +166,7 @@ class DockerTest {

@Test
void imageRun() {
def expectedImage = 'google/cloud-sdk:164.0.0'

Docker docker = createWithImage(expectedImage,
Docker docker = createWithImage(
[run: { String param1, String param2 ->
return [param1, param2]
}])
Expand All @@ -181,9 +179,7 @@ class DockerTest {

@Test
void imageWithRun() {
def expectedImage = 'google/cloud-sdk:164.0.0'

Docker docker = createWithImage(expectedImage,
Docker docker = createWithImage(
[withRun: { String param1, String param2, Closure param3 ->
return [param1, param2, param3]
}])
Expand All @@ -195,14 +191,75 @@ class DockerTest {
assertEquals('expectedClosure', args[2].call())
}

@Test
void imageInsideMountJenkinsUser() {
Docker docker = createWithImage(
[inside: { String param1, Closure param2 ->
return [param1, param2]
}])

def image = docker.image(expectedImage)
image.mountJenkinsUser = true
def args = image.inside('-v a:b') { return 'expectedClosure' }

assertEquals('-v a:b -v /home/jenkins/.jenkins/passwd:/etc/passwd:ro', args[0])
assertEquals('expectedClosure', args[1].call())
assertEquals('jenkins:x:1000:1000::/home/jenkins:/bin/sh', actualWriteFileArgs['text'])
}

@Test
void imageRunMountJenkinsUser() {
Docker docker = createWithImage(
[run: { String param1, String param2 ->
return [param1, param2]
}])

def image = docker.image(expectedImage)
image.mountJenkinsUser = true
def args = image.run('arg', 'cmd')

assertEquals('arg -v /home/jenkins/.jenkins/passwd:/etc/passwd:ro', args[0])
assertEquals('cmd', args[1])
assertEquals('jenkins:x:1000:1000::/home/jenkins:/bin/sh', actualWriteFileArgs['text'])
}

@Test
void imageWithRunMountJenkinsUser() {
Docker docker = createWithImage(
[withRun: { String param1, String param2, Closure param3 ->
return [param1, param2, param3]
}])

def image = docker.image(expectedImage)
image.mountJenkinsUser = true
def args = image.withRun('arg', 'cmd') { return 'expectedClosure' }

assertEquals('arg -v /home/jenkins/.jenkins/passwd:/etc/passwd:ro', args[0])
assertEquals('cmd', args[1])
assertEquals('expectedClosure', args[2].call())
assertEquals('jenkins:x:1000:1000::/home/jenkins:/bin/sh', actualWriteFileArgs['text'])
}

@Test
void imageMountJenkinsUserUnexpectedPasswd() {
testForInvaildPasswd('jenkins:x:1000:1000',
'/etc/passwd entry for current user does not match user:x:uid:gid:')
}

@Test
void imageMountJenkinsUserPasswdEmpty() {
testForInvaildPasswd('',
'Unable to parse user jenkins from /etc/passwd.')
}

private Docker create(Map<String, Closure> mockedMethod) {
Map<String, Map<String, Closure>> mockedScript = [
docker: mockedMethod
]
return new Docker(mockedScript)
}

private Docker createWithImage(String expectedImage, Map<String, Closure> mockedMethod) {
private Docker createWithImage(Map<String, Closure> mockedMethod) {

def mockedScript = [
docker: [image: { String id ->
Expand All @@ -212,6 +269,28 @@ class DockerTest {
}
]
]
mockedScript.put('sh', { Map<String, String> args -> return actualPasswd })
mockedScript.put('pwd', { return expectedHome })
mockedScript.put('writeFile', { Map<String, String> args -> actualWriteFileArgs = args})
mockedScript.put('error', { String arg -> throw new RuntimeException(arg) })

return new Docker(mockedScript)
}

private void testForInvaildPasswd(String invalidPasswd, String expectedError) {
Docker docker = createWithImage(
[run: { String param1, String param2 ->
return [param1, param2]
}])

actualPasswd = invalidPasswd

def image = docker.image(expectedImage)
image.mountJenkinsUser = true
def exception = shouldFail {
image.run('arg', 'cmd')
}

assertEquals(expectedError, exception.getMessage())
}
}

0 comments on commit e7a187c

Please sign in to comment.