├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package.json ├── parse.js ├── stringify.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | sandbox.js 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Mathias Buus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # containerfile 2 | 3 | Containerfile parser and stringifier 4 | 5 | ``` 6 | npm install containerfile 7 | ``` 8 | 9 | ## Usage 10 | 11 | ``` js 12 | var containerfile = require('containerfile') 13 | 14 | var parsed = containerfile.parse(` 15 | FROM ubuntu:xenial 16 | RUN rm -f /etc/resolv.conf && echo '8.8.8.8' > /etc/resolv.conf 17 | RUN apt-get update 18 | RUN apt-get install -y git vim curl 19 | RUN curl -fs https://raw.githubusercontent.com/mafintosh/node-install/master/install | sh 20 | RUN node-install 8.9.1 21 | `) 22 | 23 | // prints the parsed file 24 | console.log(parsed) 25 | 26 | // serializes it again 27 | console.log(containerfile.stringify(parsed)) 28 | ``` 29 | 30 | ## Syntax 31 | 32 | The format is similar to a `Dockerfile`. 33 | 34 | ``` 35 | FROM os:version 36 | RUN shell-command 37 | COPY from/local/file /to/container/path 38 | ENV key=value key2=value2 39 | ARG key=value 40 | ``` 41 | 42 | Alternatively if you are referencing another `Containerfile` or disk image you can do 43 | 44 | ``` 45 | FROM ./path/to/disk/image/or/containerfile 46 | ``` 47 | 48 | If your shell command is long you can split it into multiple lines using the familiar `\\` syntax 49 | 50 | ``` 51 | RUN apt-get update && \\ 52 | apt-get install -y git vim curl 53 | ``` 54 | 55 | To comment out a line add `#` infront. 56 | 57 | To force run a command (i.e. cache bust it) you can prefix any command with `FORCE`. 58 | 59 | ## API 60 | 61 | #### `var parsed = containerfile.parse(string)` 62 | 63 | Parse the content of a `Containerfile`. 64 | Returns an array of objects, each representing a line. 65 | 66 | ``` js 67 | // FROM os:version 68 | { 69 | type: 'from', 70 | image: 'os', 71 | version: 'version', 72 | path: null 73 | } 74 | 75 | // FROM ./path 76 | { 77 | type: 'from', 78 | image: null, 79 | version: null, 80 | path: './path' 81 | } 82 | 83 | // RUN command 84 | { 85 | type: 'run', 86 | command: 'command' 87 | } 88 | 89 | // COPY from to 90 | { 91 | type: 'copy', 92 | from: 'from', 93 | to: 'to' 94 | } 95 | 96 | // ENV key=value key2="value 2" ... 97 | { 98 | type: 'env', 99 | env: [{ 100 | key: 'key', 101 | value: 'value' 102 | }, { 103 | key: 'key2', 104 | value: 'value 2' 105 | }] 106 | } 107 | 108 | // ARG key=value 109 | { 110 | type: 'arg', 111 | key: 'key', 112 | value: 'value' 113 | } 114 | 115 | // ARG key 116 | { 117 | type: 'arg', 118 | key: 'key', 119 | value: null 120 | } 121 | ``` 122 | 123 | If a command is prefixed with `FORCE`, `force: true` will be set on the object. 124 | 125 | #### `var str = containerfile.stringify(parsed)` 126 | 127 | Serialize a parsed object back to the `Containerfile` format 128 | 129 | ## License 130 | 131 | MIT 132 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | exports.parse = require('./parse') 2 | exports.stringify = require('./stringify') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "containerfile", 3 | "version": "1.4.0", 4 | "description": "Containerfile parser and stringifier", 5 | "main": "index.js", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "standard": "^10.0.3", 9 | "tape": "^4.8.0" 10 | }, 11 | "scripts": { 12 | "test": "standard && tape test.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/mafintosh/containerfile.git" 17 | }, 18 | "author": "Mathias Buus (@mafintosh)", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mafintosh/containerfile/issues" 22 | }, 23 | "homepage": "https://github.com/mafintosh/containerfile" 24 | } 25 | -------------------------------------------------------------------------------- /parse.js: -------------------------------------------------------------------------------- 1 | module.exports = parse 2 | 3 | function parse (src) { 4 | src = src.toString() 5 | 6 | var result = [] 7 | var ptr = 0 8 | var cnt = 0 9 | 10 | while (ptr < src.length) { 11 | var line = parseLine() 12 | if (!line.length) continue 13 | var cmd = parseCommand(line, cnt++) 14 | if (!cmd) continue 15 | result.push(cmd) 16 | } 17 | 18 | return result 19 | 20 | function parseCommand (line, cnt) { 21 | var i = line.indexOf(' ') 22 | if (i === -1) i = line.length 23 | var type = line.slice(0, i).toLowerCase() 24 | var ptr = 0 25 | 26 | if (type[0] === '#') return null 27 | 28 | line = line.slice(i + 1).trim() 29 | 30 | if (type === 'force') { 31 | var next = parseCommand(line, cnt) 32 | next.force = true 33 | return next 34 | } 35 | 36 | switch (type) { 37 | case 'from': 38 | var path = /[./"'`]/.test(line[0]) ? parseString() : null 39 | var image = path ? null : line.split(':')[0] 40 | var version = path ? null : line.split(':')[1] || null 41 | return {type: type, image: image, version: version, path: path} 42 | case 'env': 43 | return {type: type, env: parseKeyValue()} 44 | case 'user': 45 | return {type: type, user: parseString()} 46 | case 'arg': 47 | return parseArg() 48 | case 'run': 49 | return {type: type, command: line} 50 | case 'cmd': 51 | return {type: type, command: line} 52 | case 'mount': 53 | return {type: type, from: parseString(), to: parseString()} 54 | case 'copy': 55 | return {type: type, from: parseString(), to: parseString()} 56 | default: 57 | throw new Error('Unknown type: ' + type + ' at line ' + cnt) 58 | } 59 | 60 | function parseArg () { 61 | // ARG NAME=VALUE? 62 | var i = line.indexOf('=', ptr) 63 | if (i === -1) return {type: 'arg', key: parseString(), value: null} 64 | return {type: 'arg', key: parseKey(), value: parseString()} 65 | } 66 | 67 | function parseKeyValue () { 68 | var env = [] 69 | var i = line.indexOf('=', ptr) 70 | var space = line.indexOf(' ', ptr) 71 | 72 | if (i === -1 || (space < i && space > -1)) { 73 | // ENV NAME VALUE 74 | env.push({key: parseString(), value: parseString()}) 75 | } else { 76 | // ENV NAME=VALUE 77 | while (ptr < line.length) { 78 | env.push({key: parseKey(), value: parseString()}) 79 | } 80 | } 81 | 82 | return env 83 | } 84 | 85 | function parseKey () { 86 | var i = line.indexOf('=', ptr) 87 | if (i === -1) throw new Error('Expected key=value at line ' + cnt) 88 | var key = line.slice(ptr, i).trim() 89 | ptr = i + 1 90 | return key 91 | } 92 | 93 | function parseString () { 94 | for (; ptr < line.length; ptr++) { 95 | if (!/\s/.test(line[ptr])) break 96 | } 97 | 98 | if (ptr === line.length) throw new Error('Expected string at line ' + cnt) 99 | 100 | var end = /["'`]/.test(line[ptr]) ? line[ptr++] : ' ' 101 | var prev = ptr 102 | var skip = false 103 | var tmp = '' 104 | 105 | for (; ptr < line.length; ptr++) { 106 | if (skip) { 107 | prev = ptr 108 | skip = false 109 | continue 110 | } 111 | if (line[ptr] === '\\') { 112 | tmp += line.slice(prev, ptr) 113 | prev = ptr 114 | skip = true 115 | continue 116 | } 117 | if (line[ptr] === end) { 118 | return tmp + line.slice(prev, ptr++) 119 | } 120 | } 121 | 122 | if (end !== ' ') throw new Error('Missing ' + end + ' at line ' + cnt) 123 | return tmp + line.slice(prev) 124 | } 125 | } 126 | 127 | function parseLine () { 128 | for (; ptr < src.length; ptr++) { 129 | if (!/\s/.test(src[ptr])) break 130 | } 131 | 132 | var prev = ptr 133 | var lines = [] 134 | 135 | for (; ptr < src.length; ptr++) { 136 | if (src[ptr] === '\\' && src[ptr + 1] === '\r' && src[ptr + 2] === '\n') { 137 | lines.push(src.slice(prev, ptr).trim()) 138 | ptr += 2 139 | prev = ptr + 2 140 | continue 141 | } 142 | if (src[ptr] === '\\' && src[ptr + 1] === '\n') { 143 | lines.push(src.slice(prev, ptr).trim()) 144 | ptr++ 145 | prev = ptr + 1 146 | continue 147 | } 148 | if (src[ptr] === '\n') { 149 | break 150 | } 151 | } 152 | 153 | if (prev < ptr) lines.push(src.slice(prev, ptr).trim()) 154 | return lines.join(' ').trim() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /stringify.js: -------------------------------------------------------------------------------- 1 | module.exports = stringify 2 | 3 | function stringify (parsed) { 4 | return parsed.map(function (cmd) { 5 | var prefix = cmd.force ? 'FORCE ' : '' 6 | 7 | switch (cmd.type) { 8 | case 'from': 9 | if (cmd.path) return prefix + 'FROM ' + JSON.stringify(cmd.path) + '\n' 10 | return prefix + 'FROM ' + cmd.image + (cmd.version ? ':' + cmd.version : '') + '\n' 11 | case 'run': 12 | return prefix + 'RUN ' + cmd.command + '\n' 13 | case 'cmd': 14 | return prefix + 'CMD ' + cmd.command + '\n' 15 | case 'mount': 16 | return prefix + 'MOUNT ' + cmd.command + '\n' 17 | case 'user': 18 | return prefix + 'USER ' + cmd.user + '\n' 19 | case 'env': 20 | return prefix + 'ENV ' + cmd.env.map(toKeyValue).join(' ') + '\n' 21 | case 'arg': 22 | return prefix + 'ARG ' + cmd.key + (cmd.value ? '=' + JSON.stringify(cmd.value) : '') + '\n' 23 | case 'copy': 24 | return prefix + 'COPY ' + JSON.stringify(cmd.from) + ' ' + JSON.stringify(cmd.to) + '\n' 25 | default: 26 | throw new Error('Unknown type: ' + cmd.type) 27 | } 28 | }).join('') 29 | } 30 | 31 | function toKeyValue (env) { 32 | return env.key + '=' + JSON.stringify(env.value) 33 | } 34 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var tape = require('tape') 2 | var parse = require('./parse') 3 | var stringify = require('./stringify') 4 | 5 | tape('parses', function (t) { 6 | var parsed = parse(` 7 | FROM ubuntu:precise 8 | RUN rm -f /etc/resolv.conf && echo '8.8.8.8' > /etc/resolv.conf 9 | RUN apt-get update 10 | RUN apt-get install -y python-software-properties && \\ 11 | add-apt-repository -y ppa:ubuntu-toolchain-r/test && \\ 12 | apt-get update 13 | RUN apt-get install -y g++-4.8 g++-4.8-multilib gcc-4.8-multilib && \\ 14 | update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.8 50 15 | RUN foo \\ 16 | bar \\ 17 | baz 18 | COPY ./start.js /root/start.js 19 | COPY "./start.js" /root/start.js 20 | COPY " a b c " 'd e f' 21 | COPY foo\\ bar.js baz 22 | `) 23 | 24 | var expected = [{ 25 | type: 'from', 26 | image: 'ubuntu', 27 | version: 'precise', 28 | path: null 29 | }, { 30 | type: 'run', 31 | command: 'rm -f /etc/resolv.conf && echo \'8.8.8.8\' > /etc/resolv.conf' 32 | }, { 33 | type: 'run', 34 | command: 'apt-get update' 35 | }, { 36 | type: 'run', 37 | command: 'apt-get install -y python-software-properties && add-apt-repository -y ppa:ubuntu-toolchain-r/test && apt-get update' 38 | }, { 39 | type: 'run', 40 | command: 'apt-get install -y g++-4.8 g++-4.8-multilib gcc-4.8-multilib && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.8 50' 41 | }, { 42 | type: 'run', 43 | command: 'foo bar baz' 44 | }, { 45 | type: 'copy', 46 | from: './start.js', 47 | to: '/root/start.js' 48 | }, { 49 | type: 'copy', 50 | from: './start.js', 51 | to: '/root/start.js' 52 | }, { 53 | type: 'copy', 54 | from: ' a b c ', 55 | to: 'd e f' 56 | }, { 57 | type: 'copy', 58 | from: 'foo bar.js', 59 | to: 'baz' 60 | }] 61 | 62 | t.same(parsed.length, expected.length) 63 | for (var i = 0; i < parsed.length; i++) { 64 | t.same(parsed[i], expected[i]) 65 | } 66 | t.end() 67 | }) 68 | 69 | tape('force', function (t) { 70 | t.same(parse('FORCE RUN foo'), [{type: 'run', command: 'foo', force: true}]) 71 | t.end() 72 | }) 73 | 74 | tape('other from', function (t) { 75 | t.same(parse('FROM ./path'), [{type: 'from', image: null, version: null, path: './path'}]) 76 | t.same(parse('FROM "./path space"'), [{type: 'from', image: null, version: null, path: './path space'}]) 77 | t.end() 78 | }) 79 | 80 | tape('arg', function (t) { 81 | t.same(parse('ARG foo=bar\nARG foo'), [{ 82 | type: 'arg', 83 | key: 'foo', 84 | value: 'bar' 85 | }, { 86 | type: 'arg', 87 | key: 'foo', 88 | value: null 89 | }]) 90 | t.end() 91 | }) 92 | 93 | tape('env', function (t) { 94 | t.same(parse('ENV key value'), [{ 95 | type: 'env', 96 | env: [{ 97 | key: 'key', 98 | value: 'value' 99 | }] 100 | }]) 101 | 102 | t.same(parse('ENV key=value'), [{ 103 | type: 'env', 104 | env: [{ 105 | key: 'key', 106 | value: 'value' 107 | }] 108 | }]) 109 | 110 | t.same(parse('ENV key1=value1 key2=value2'), [{ 111 | type: 'env', 112 | env: [{ 113 | key: 'key1', 114 | value: 'value1' 115 | }, { 116 | key: 'key2', 117 | value: 'value2' 118 | }] 119 | }]) 120 | 121 | t.end() 122 | }) 123 | 124 | tape('stringify', function (t) { 125 | t.same(stringify([{type: 'from', image: 'arch'}]), 'FROM arch\n') 126 | 127 | var input = [{ 128 | type: 'from', 129 | path: './foo' 130 | }, { 131 | type: 'arg', 132 | key: 'foo', 133 | value: 'bar' 134 | }, { 135 | type: 'run', 136 | command: 'echo hello' 137 | }, { 138 | type: 'copy', 139 | from: 'a', 140 | to: 'b' 141 | }, { 142 | type: 'env', 143 | env: [{ 144 | key: 'hello', 145 | value: 'world' 146 | }, { 147 | key: 'key', 148 | value: 'bunch of spaces' 149 | }] 150 | }] 151 | 152 | t.same(stringify(input), 'FROM "./foo"\nARG foo="bar"\nRUN echo hello\nCOPY "a" "b"\nENV hello="world" key="bunch of spaces"\n') 153 | t.same(noNull(parse(stringify(input))), input) 154 | t.end() 155 | }) 156 | 157 | function noNull (inp) { 158 | inp.forEach(function (i) { 159 | Object.keys(i).forEach(function (k) { 160 | if (i[k] === null) delete i[k] 161 | }) 162 | }) 163 | return inp 164 | } 165 | --------------------------------------------------------------------------------