From 8d214130e3e67db6abd81f2d2d54632c434a98b8 Mon Sep 17 00:00:00 2001 From: Vincent Weevers Date: Fri, 15 Jul 2016 12:07:18 +0200 Subject: [PATCH] initial --- .gitignore | 15 ++++ LICENSE | 21 +++++ bin.js | 85 ++++++++++++++++++ index.js | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 29 ++++++ readme.md | 87 ++++++++++++++++++ 6 files changed, 485 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 bin.js create mode 100644 index.js create mode 100644 package.json create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78cdbf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz + +pids +logs +results + +npm-debug.log +node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a83be31 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Vincent Weevers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bin.js b/bin.js new file mode 100644 index 0000000..88daef9 --- /dev/null +++ b/bin.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +'use strict'; + +const yargs = require('yargs') + , Shares = require('./') + , pkg = require('./package.json') + +const name = pkg.name +const description = pkg.description + +yargs.usage(`Usage: ${name} [options]\n\n${description}`) + +yargs.options({ + machine: { + alias: 'm', + description: 'Machine name (DOCKER_MACHINE_NAME or "default")', + type: 'string' + }, + verbose: { + description: 'Verbose output', + type: 'boolean' + } +}) + +yargs.global(['machine', 'verbose']) + .group (['machine', 'verbose'], 'Global options:') + +yargs.command('mount', 'Create shared folder and mount it') + .command('unmount', 'Unmount and remove shared folder if it exists') + +yargs.epilogue(`Run '${name} --help' for more information on a command.`) + +const verbose = yargs.argv.verbose +const command = yargs.argv._[0] + +if (command === 'mount') { + yargs.reset() + .usage( + `Usage: ${name} mount [local path] [guest path]\n\n` + + 'Creates a VirtualBox shared folder and mounts it inside the VM. ' + + 'Without arguments, the local and guest path would respectively default to:\n' + + '\n- ' + process.cwd() + + '\n- ' + Shares.unixify(process.cwd()) + ) + .option('name', { + alias: 'n', + description: 'Shared folder name (basename of local path)', + type: 'string' + }) + .option('readonly', { + alias: 'r', + description: 'Create read-only shared folder', + type: 'boolean' + }) + .option('transient', { + alias: 't', + description: 'Create temporary shared folder', + type: 'boolean' + }) + .help() + .example(`${name} mount`, 'Mount the current working directory') + .example(`${name} mount -tr`, 'Read-only and temporary') + .example(`${name} mount . /beep`, 'Mount working directory at /beep') + .argv + + const opts = yargs.argv + const args = yargs.argv._.slice(1) + + opts.hostPath = args[0] + opts.guestPath = args[1] + + new Shares({ machine: opts.machine }).mount(opts, bail) +} else { + yargs.showHelp() +} + +function bail (err) { + if (err && verbose) { + throw err + } else if (err) { + process.stderr.write(err.message + '.\n') + process.exit(1) + } +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..d049c7b --- /dev/null +++ b/index.js @@ -0,0 +1,248 @@ +'use strict'; + +const path = require('path') + , fs = require('fs') + , env = require('xtend')(process.env) + , cp = require('child_process') + , Machine = require('docker-machine') + , unixify = require('unixify') + , series = require('run-series') + , debug = require('debug')('docker-share') + +if (env.VBOX_MSI_INSTALL_PATH) { + env.PATH+= path.delimiter + env.VBOX_MSI_INSTALL_PATH +} + +class VirtualBox { + constructor(name) { + this.name = name + } + + command(args, done) { + debug('vboxmanage', args) + + cp.execFile('vboxmanage', args, { + env: env, + encoding: 'utf8' + }, done) + } + + addShare(name, hostPath, opts, done) { + if (typeof opts === 'function') done = opts, opts = {} + else if (!opts) opts = {} + + const args = ['sharedfolder', 'add', this.name] + + args.push('--name', name) + args.push('--hostpath', hostPath) + + if (opts.writable === false || opts.readonly) args.push('--readonly') + if (opts.transient) args.push('--transient') + + this.command(args, done) + } + + removeShare() { + + } + + info(done) { + this.command(['showvminfo', this.name], done) + } + + getShares(done) { + this.info((err, info) => { + if (err) return done(err) + + const re = /Name: '(.*)', Host path: '(.*)'(.*)/g + const shares = {} + + let match; + while(match = re.exec(info)) { + const share = { name: match[1], hostPath: match[2] } + + ;(match[3] || '').split(',').forEach(prop => { + // "(transient mapping)" => share.transient = true + prop = prop.trim().replace(/^\(|\)$/g, '') + + if (prop === 'transient mapping') prop = 'transient' + else if (prop === 'machine mapping') return + + share[prop] = true + }) + + shares[share.name] = share + } + + debug(shares) + done(null, shares) + }) + } + + getShare(name, done) { + this.getShares((err, shares) => { + if (err) done(err) + else done(null, shares[name] || null) + }) + } + + hasShare(name, done) { + this.getShare(name, (err, share) => { + if (err) done(err) + else done(null, !!share) + }) + } + + getState(done) { + this.info((err, info) => { + if (err) return done(err) + + const m = /State:\s+(.+)/.exec(info) + if (!m) return done(new Error('Could not find state')) + + done(null, m[1]) + }) + } +} + +class Shares { + constructor(opts) { + this.machine = new Machine({ name: opts.machine }) + this.vm = new VirtualBox(this.machine.name) + } + + static unixify(path) { + const unix = unixify(path) + const m = path.match(/^([A-Z]):/i) + return m ? '/' + m[1].toLowerCase() + unix : unix + } + + mount(opts, done) { + if (typeof opts === 'function') done = opts, opts = {} + else if (!opts) opts = {} + + const hostPath = path.resolve(opts.hostPath || '.') + const name = opts.name || path.basename(hostPath) + const guestPath = Shares.unixify(opts.guestPath || hostPath) + const state = {} + + debug('Mounting %s..', name) + + series([ + (next) => { + this.vm.getState((err, state) => { + if (opts.transient && !/^running/.test(state)) { + return next(new Error( + 'VM must be running to create a transient shared folder' + )) + } + + // TODO: can't mount it + if (!opts.transient && !/^powered off/.test(state)) { + return next(new Error( + 'VM must be powered off to create a permanent shared folder' + )) + } + + next() + }) + }, + + (next) => { + this.vm.getShare(name, (err, share) => { + if (err) return next(err) + state.share = share + next() + }) + }, + + (next) => { + // TODO: compare properties + if (state.share) next() + else this.vm.addShare(name, hostPath, opts, next) + }, + + (next) => { + this.getFilesystem(name, (err, fs) => { + if (err) return next(err) + state.fs = fs + next() + }) + }, + + (next) => { + if (state.fs) { + if (state.fs.type !== 'vboxsf') { + return next(new Error( + `Mount conflict: existing filesystem of type "${state.fs.type}"` + )) + } + + if (state.fs.path !== guestPath) { + return next(new Error( + `Mount conflict: existing filesystem at path "${state.fs.path}"` + )) + } + + return next() + } + + this.mountFilesystem(name, guestPath, next) + }, + + (next) => { + process.stdout.write('Testing ' + guestPath + '.. ') + + this.machine.ssh('ls ' + guestPath, (err) => { + if (err) process.stdout.write('FAIL\n') + else process.stdout.write('OK\n') + next(err) + }) + } + ], done) + } + + getFilesystems(done) { + debug('mount') + + this.machine.ssh('mount', (err, result) => { + if (err) return done(err) + + const re = /^([^ ]+) on ([^ ]+) type ([^ ]+)/igm + const filesystems = {} + + let match; + while(match = re.exec(result)) { + const mp = { name: match[1], path: match[2], type: match[3] } + filesystems[mp.name] = mp + } + + debug(filesystems) + done(null, filesystems) + }) + } + + getFilesystem(name, done) { + this.getFilesystems((err, filesystems) => { + if (err) done(err) + else done(null, filesystems[name] || null) + }) + } + + mountFilesystem(name, guestPath, done) { + const mkdir = `sudo mkdir -p "${guestPath}"` + debug(mkdir) + + this.machine.ssh(mkdir, (err) => { + if (err) return done(err) + + const opt = 'defaults,uid=`id -u docker`,gid=`id -g docker`' + const mount = `sudo mount -t vboxsf -o ${opt} ${name} ${guestPath}` + + debug(mount) + this.machine.ssh(mount, done) + }) + } +} + +module.exports = Shares diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a6063d --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "docker-share", + "version": "0.0.1", + "description": "Share local folders with a Docker Machine VM", + "license": "MIT", + "bin": "bin.js", + "author": "Vincent Weevers", + "scripts": {}, + "dependencies": { + "debug": "~2.2.0", + "docker-machine": "~1.0.0", + "run-series": "~1.1.4", + "unixify": "~0.2.1", + "xtend": "~4.0.1", + "yargs": "~4.8.0" + }, + "devDependencies": {}, + "keywords": [ + "docker-machine", + "docker", + "machine", + "mount", + "share" + ], + "engines": { + "node": ">=0.4.0", + "npm": ">=2.0.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..3288dd7 --- /dev/null +++ b/readme.md @@ -0,0 +1,87 @@ +# docker-share + +**Share local folders with a Docker Machine VM. Currently only capable of adding and mounting transient shares on a running VM.** + +[![node](https://img.shields.io/node/v/docker-share.svg?style=flat-square)](https://www.npmjs.org/package/docker-share) [![npm status](http://img.shields.io/npm/v/docker-share.svg?style=flat-square)](https://www.npmjs.org/package/docker-share) [![Dependency status](https://img.shields.io/david/vweevers/node-docker-share.svg?style=flat-square)](https://david-dm.org/vweevers/node-docker-share) + +## motivation + +On Windows with Docker Toolbox, one [can't mount data volumes outside of `C:\Users`](https://github.com/docker/compose/issues/2548). This is especially annoying with Docker Compose and relative data volumes like `.:/code`. To remedy this (without migrating to [Windows 10 with Docker for Windows](https://github.com/docker/compose/issues/2548#issuecomment-232415158)), we can add a project's directory as a VirtualBox shared folder and mount it inside the Docker Machine VM - at the exact same path as on the Windows box so that relative volumes resolve correctly. This tool does this for you (and more, like checking if the share already exists). Its main functionality is roughly equivalent to: + +```batch +cd C:\projects\my-project + +vboxmanage sharedfolder add default ^ + --name my-project --hostpath "%cd%" ^ + --transient + +docker-machine ssh default sudo ^ + mkdir -p /c/projects/my-project + +docker-machine ssh default sudo ^ + mount -t vboxsf -o ^ + defaults,uid=`id -u docker`,gid=`id -g docker` ^ + my-project /c/projects/my-project +``` + +This tool should work on other platforms too. If you've found a use for it, let me know! Or just do a little dance. + +## example + +These commands should be run after `docker-machine start`, but before `docker run` or `docker-compose up`. Mount the current working directory: + +``` +docker-share mount --transient +``` + +Mount the current working directory, transient and read-only, at `/home/docker/beep` on a non-default Docker Machine: + +``` +docker-share mount -m my-machine -tr . /home/docker/beep +``` + +## roadmap + +- [x] Mount transient share +- [ ] Mount permanent share (check state, stop VM if `--force`, edit boot script, restart) +- [ ] Unmount + +## usage + +### `docker-share [options]` + +``` +Commands: + mount Create shared folder and mount it + unmount Unmount and remove shared folder if it exists + +Global options: + --machine, -m Machine name (DOCKER_MACHINE_NAME or "default") [string] + --verbose Verbose output [boolean] + +Run 'docker-share --help' for more information on a command. +``` + +#### `mount [local path] [guest path]` + +Creates a VirtualBox shared folder and mounts it inside the VM. Without arguments, the local and guest path default to the current working directory. A guest path like `C:\project` is converted to a cygwin-style `/c/project`. + +``` +Options: + --name, -n Shared folder name (basename of local path) [string] + --readonly, -r Create read-only shared folder [boolean] + --transient, -t Create temporary shared folder [boolean] + --help Show help [boolean] +``` + +## install + +With [npm](https://npmjs.org) do: + +``` +npm install docker-share --global +``` + +## license + +[MIT](http://opensource.org/licenses/MIT) © Vincent Weevers