Permalink
Browse files

feat(images): Build Docker image in absence of from field

  • Loading branch information...
TomFrost committed Nov 12, 2017
1 parent 3cc56ad commit 737ef4596b8ba897b76c7cc6b17605b5c8ec314a
Showing with 366 additions and 18 deletions.
  1. 0 index.js
  2. +2 −2 src/config.js
  3. +140 −0 src/images.js
  4. +39 −8 src/index.js
  5. +5 −0 test/fixtures/known_sha1.txt
  6. +135 −0 test/src/images.spec.js
  7. +45 −8 test/src/index.spec.js
View
0 index.js 100644 → 100755
No changes.
View
@@ -38,12 +38,12 @@ const config = {
},
/**
* Filters out empty config properties
* @param {object}
* @param {object} cfg
* @returns {object}
*/
validate: (cfg) => _.pipe([
_.toPairs,
_.reject(([key, val]) => !_.contains(key, ['from', 'tasks']) && _.isEmpty(val) || _.isNil(val)),
_.reject(([key, val]) => !_.contains(key, ['tasks']) && _.isEmpty(val) || _.isNil(val)),
_.fromPairs
])(cfg)
}
View
@@ -0,0 +1,140 @@
'use strict'
const proc = require('./proc')
const output = require('./output')
const cp = require('child_process')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const images = {
/**
* Builds a docker image, naming it according to the parent folder and tagging it
* with "bc_" followed by the first 8 characters of the sha1 hash of the Dockerfile
* used to build it.
* @param dockerfile
* @param imageName
* @returns {Promise.<T>}
*/
buildImage: (dockerfile, imageName) => {
output.info(`Building image from ${dockerfile}`)
output.line()
return proc.run([
'build',
'-f', path.resolve(process.cwd(), dockerfile),
'-t', imageName,
process.cwd()
]).then(() => {
output.line()
output.success('Image built successfully!')
return imageName
}).catch(e => {
output.line()
output.error('Build failed')
throw e
})
},
/**
* Deletes a docker image
* @param {Promise} imageName The name of the image to be deleted in the format name:tag
*/
deleteImage: (imageName) => Promise.resolve().then(() => {
const delSpinner = output.spinner(`Deleting old image: ${imageName}`)
const cmd = `docker rmi ${imageName}`
try {
cp.execSync(cmd)
} catch (e) {
delSpinner.fail()
throw e
}
delSpinner.succeed()
}),
/**
* Searches for images that have been been automatically built by binci for the
* current project, and returns them as an array of objects containing the fields
* "hash" (the truncated sha1 of the Dockerfile that built the image), and "createdAt"
* (the Epoch time of image creation).
* @returns {Promise.<Array<{id:string},{hash:string},{createdAt:number}>>} the
* array of images pertaining to this project
*/
getBuiltImages: () => Promise.resolve().then(() => {
const cmd = [
'docker images',
'--format',
`'{{"{"}}"tag":"{{.Tag}}","createdAt":"{{.CreatedAt}}"{{"}"}}'`,
'"--filter=reference=' + images.getProjectName() + ':bc_*"'
].join(' ')
const out = cp.execSync(cmd).toString()
const parsed = JSON.parse('[' + out.replace(/\s*$/g, '').split('\n').join(',') + ']')
return parsed.map(elem => ({
hash: elem.tag.substr(3),
createdAt: new Date(elem.createdAt).getTime()
}))
}),
/**
* Gets the SHA-1 checksum, truncated to 12 hexadecimal digits, of the contents of the file at path.
* @param {string} path The path of the file for the checksum
* @returns {Promise.<string|null>} The sha1 as a hex string, or null if the file does not exist.
*/
getHash: (path) => new Promise((resolve, reject) => {
const shasum = crypto.createHash('sha1')
const stream = fs.createReadStream(path)
stream.on('error', err => {
if (err.code && err.code === 'ENOENT') resolve(null)
else reject(err)
})
stream.on('data', data => shasum.update(data))
stream.on('close', () => resolve(shasum.digest('hex').substr(0, 12)))
}),
/**
* Gets a valid image ID that can be used to run a new docker container. This may
* be either a hexadecimal image ID, or a name:tag string. If an image has already
* been built for this dockerfile, the ID of the existing image will be returned.
* If no build has happened yet or the dockerfile has been changed since the last
* build, a new build will be run, and the previous image will be deleted (if one
* exists).
* @param {String} [dockerfile="./Dockerfile"] The path to the dockerfile to be
* used for building the new image or retrieving the existing one
* @returns {Promise.<string>} the ID of the image to be used
*/
getImage: (dockerfile = './Dockerfile') => {
return Promise.all([
images.getHash(dockerfile),
images.getBuiltImages()
]).then(([ hash, imgs ]) => {
if (!hash) {
throw new Error(`No "from" specified, and ${dockerfile} does not exist.`)
}
const [ image ] = imgs.filter(img => img.hash === hash)
if (image) return images.getImageNameFromHash(hash)
// Find the most recent binci build so we can delete it after the new one builds
const mostRecent = imgs.reduce((acc, elem) => {
if (acc.createdAt > elem.createdAt) return acc
return elem
}, {hash: null, createdAt: 0})
const imageName = images.getImageNameFromHash(hash)
return images.buildImage(dockerfile, imageName)
.then(imageName => {
if (mostRecent.hash) {
return images.deleteImage(images.getImageNameFromHash(mostRecent.hash))
.then(() => imageName)
}
return imageName
})
})
},
/**
* Constructs a full name:tag string for a given Dockerfile hash for this project
* @param {string} hash The Dockerfile hash of the build
* @returns {string} The fully qualified image name
*/
getImageNameFromHash: (hash) => `${images.getProjectName()}:bc_${hash}`,
/**
* Gets the name of the project binci is running for. This is simply the name of
* the directory in which binci has been executed.
* @returns {string} the project name
*/
getProjectName: () => path.basename(process.cwd())
}
module.exports = images
View
@@ -13,6 +13,7 @@ const proc = require('./proc')
const pkg = require('../package.json')
const output = require('./output')
const utils = require('./utils')
const images = require('./images')
const tmpdir = require('./tempdir')()
@@ -29,18 +30,29 @@ const instance = {
*/
startTS: Date.now(),
/**
* Gets config by merging parsed arguments with config object and returns command
* instructions for primaary instance and services.
* @returns {object} Command instructions
* Gets the project config by loading the config file and merging it with applicable command line
* arguments.
* @returns {Promise.<Object>} an object representing the Binci config for this project
*/
getConfig: (rmOnShutdown) => {
getProjectConfig: () => {
return Promise.resolve()
.then(args.parse)
.then(parsedArgs => {
const cfg = services.filterEnabled(_.merge(config.load(parsedArgs.configPath), parsedArgs))
return { services: services.get(cfg), primary: command.get(_.merge(cfg, { rmOnShutdown }), 'primary', tmpdir, true) }
const initConfig = config.load(parsedArgs.configPath)
return services.filterEnabled(_.merge(initConfig, parsedArgs))
})
},
/**
* Gets the runtime configuration by adapting the project config into an object that describes
* only the things applicable to the current binci execution.
* @param {Object} projConfig A project config object
* @param {boolean} [rmOnShutdown=false] true to delete the main container automatically when stopped
* @returns {object} Command instructions
*/
getRunConfig: (projConfig, rmOnShutdown) => ({
services: services.get(projConfig),
primary: command.get(_.merge(projConfig, { rmOnShutdown }), 'primary', tmpdir, true)
}),
/**
* Starts services and resolves or rejects
* @param {object} cfg Instance config object
@@ -80,7 +92,7 @@ const instance = {
},
/**
* Runs primary command
* @param {object} config The instance config object
* @param {object} cfg The instance config object
* @returns {object} promise
*/
runCommand: (cfg) => {
@@ -97,14 +109,33 @@ const instance = {
throw new Error('Command failed')
})
},
/**
* Checks to see if the provided config has a `from` field. If not, the docker
* image will be built (if it hasn't already) and the resulting image ID will be
* saved back to the config object as `from`.
* @param {Object} cfg The instance config object
* @returns {Promise.<Object>} The modified config object
*/
attachFrom: (cfg) => {
if (!cfg.from) {
return images.getImage(cfg.dockerfile)
.then(imageId => {
cfg.from = imageId
return cfg
})
}
return Promise.resolve(cfg)
},
/**
* Initializes instance from config and args
* @returns {object} promise
*/
start: () => Promise.resolve()
.then(instance.checkForUpdates)
.then(utils.checkVersion)
.then(instance.getConfig)
.then(instance.getProjectConfig)
.then(cfg => instance.attachFrom(cfg))
.then(cfg => instance.getRunConfig(cfg))
.then(cfg => {
// Write the primary command to tmp script
return fs.writeFileAsync(`${tmpdir}/binci.sh`, cfg.primary.cmd)
@@ -0,0 +1,5 @@
This file has a known SHA-1 hash that's built into tests.
Please don't change it unless you want to break tests.
And if you do want to break tests ... you're a meanie.
View
@@ -0,0 +1,135 @@
const images = require('src/images')
const output = require('src/output')
const proc = require('src/proc')
const sandbox = require('test/sandbox')
const path = require('path')
const fs = require('fs')
const Promise = require('bluebird')
const cp = require('child_process')
Promise.promisifyAll(fs)
const knownShaPath = path.resolve(__dirname, '../fixtures/known_sha1.txt')
const knownSha = 'a2ccc6a4b1cfb10bd2970f37b61ef01b2f0f351a'
describe('images', () => {
beforeEach(() => {
sandbox.stub(process, 'cwd', () => '/tmp')
sandbox.stub(output, 'spinner', () => {
return { succeed: () => null, fail: () => null }
})
sandbox.stub(output, 'line')
sandbox.stub(output, 'success')
sandbox.stub(output, 'warn')
sandbox.stub(output, 'error')
})
describe('getProjectName', () => {
it('determines the project name from the current working directory', () => {
expect(images.getProjectName()).to.equal('tmp')
})
})
describe('getHash', () => {
it('determines the SHA-1 hash of an existing file', () => {
return images.getHash(knownShaPath).then(hash => {
expect(hash).to.equal(knownSha.substr(0, 12))
})
})
it('returns null if the file does not exist', () => {
return images.getHash(knownShaPath + 'notfound').then(hash => {
expect(hash).to.be.null()
})
})
it('fails on file read errors', () => {
return expect(images.getHash(process.cwd())).to.be.rejected()
})
})
describe('getBuiltImages', () => {
it('gets and processes a list of images', () => {
sandbox.stub(cp, 'execSync', () => ({ toString: () => (
'{"tag":"bc_deadbeefbeef","createdAt":"2017-11-07 15:03:13 -0500 EST"}\n' +
'{"tag":"bc_deadb00fb00f","createdAt":"2017-11-07 15:03:12 -0500 EST"}'
)}))
return images.getBuiltImages().then(images => {
expect(images).to.deep.equal([
{hash: 'deadbeefbeef', 'createdAt': 1510084993000},
{hash: 'deadb00fb00f', 'createdAt': 1510084992000}
])
})
})
it('returns an empty array when there are no images', () => {
sandbox.stub(cp, 'execSync', () => ({ toString: () => '' }))
return images.getBuiltImages().then(images => {
expect(images).to.deep.equal([])
})
})
})
describe('deleteImage', () => {
it('executes the delete command successfully', () => {
let cmd
sandbox.stub(cp, 'execSync', (c) => { cmd = c })
return images.deleteImage('foo').then(() => {
expect(cmd).to.equal('docker rmi foo')
})
})
it('rejects when delete fails', () => {
sandbox.stub(cp, 'execSync', () => { throw new Error('test rejection') })
return expect(images.deleteImage('foo')).to.be.rejected()
})
})
describe('buildImage', () => {
it('runs the build command successfully', () => {
let args
sandbox.stub(proc, 'run', (a) => {
args = a
return Promise.resolve()
})
return images.buildImage('./Foo', 'bar').then(() => {
expect(args[2]).to.equal('/tmp/Foo')
expect(args[4]).to.equal('bar')
})
})
it('rejects when the command fails', () => {
sandbox.stub(proc, 'run', () => Promise.reject(new Error('test rejection')))
return expect(images.buildImage('./Foo', 'bar')).to.be.rejected()
})
})
describe('getImage', () => {
it('returns the name of an existing image when it matches', () => {
sandbox.stub(images, 'getHash', () => 'deadbeefbeef')
sandbox.stub(images, 'getBuiltImages', () => [
{hash: 'deadbeefbeef', createdAt: 1510084993000}
])
return images.getImage().then(id => {
expect(id).to.equal('tmp:bc_deadbeefbeef')
})
})
it('rejects when the Dockerfile is not found', () => {
sandbox.stub(images, 'getHash', () => null)
sandbox.stub(images, 'getBuiltImages', () => [
{hash: 'deadbeefbeef', createdAt: 1510084993000}
])
return expect(images.getImage()).to.be.rejectedWith(/does not exist/)
})
it('builds a new image if the hash is not found', () => {
sandbox.stub(images, 'getHash', () => 'deadbeefbeef')
sandbox.stub(images, 'getBuiltImages', () => [])
const spy = sandbox.stub(images, 'buildImage', (df, name) => Promise.resolve(name))
return images.getImage('df').then(() => {
expect(spy).to.be.calledOnce()
expect(spy).to.be.calledWith('df', 'tmp:bc_deadbeefbeef')
})
})
it('deletes an old image after a successful build', () => {
sandbox.stub(images, 'getHash', () => 'deadb00fb00f')
sandbox.stub(images, 'getBuiltImages', () => [
{hash: 'deadbeefbeef', createdAt: 1510084993000},
{hash: 'deadbaafbaaf', createdAt: 1510084992000}
])
sandbox.stub(images, 'buildImage', (df, name) => Promise.resolve(name))
const spy = sandbox.stub(images, 'deleteImage', () => Promise.resolve())
return images.getImage().then(() => {
expect(spy).to.be.calledOnce()
expect(spy).to.be.calledWith('tmp:bc_deadbeefbeef')
})
})
})
})
Oops, something went wrong.

0 comments on commit 737ef45

Please sign in to comment.