diff --git a/plugins/providers/docker/README.md b/plugins/providers/docker/README.md new file mode 100644 index 000000000..e83e5efae --- /dev/null +++ b/plugins/providers/docker/README.md @@ -0,0 +1,206 @@ +# docker-provider + +[![Build Status](https://travis-ci.org/fgrehm/docker-provider.png?branch=master)](https://travis-ci.org/fgrehm/docker-provider) [![Gem Version](https://badge.fury.io/rb/docker-provider.png)](http://badge.fury.io/rb/docker-provider) [![Gittip](http://img.shields.io/gittip/fgrehm.svg)](https://www.gittip.com/fgrehm/) + +A [Docker](http://www.docker.io/) provider for [Vagrant](http://www.vagrantup.com/) +1.4+. + + +## Warning + +This is experimental, expect things to break. + + +## Requirements + +* Vagrant 1.4+ +* Docker 0.7.0+ + + +## Features + +* Support for Vagrant's `up`, `destroy`, `halt`, `reload` and `ssh` commands +* Port forwarding +* Synced / shared folders support +* Set container hostnames from Vagrantfiles +* Provision Docker containers with any built-in Vagrant provisioner (as long as the container has a SSH server running) + +You can see the plugin in action by watching the following asciicasts I published +prior to releasing 0.0.1: + +* http://asciinema.org/a/6162 +* http://asciinema.org/a/6177 + + +## Getting started + +If you are on a Mac / Windows machine, please fire up a x64 Linux VM with Docker + +Vagrant 1.4+ installed or use [this Vagrantfile](https://gist.github.com/fgrehm/fc48fb51ec7df64439e4) +and follow the instructions from within the VM. + +_It is likely that the plugin works with [boot2docker](http://boot2docker.github.io/) +but I personally haven't tried that yet. If you are able to give it a go please +[let me know](https://github.com/fgrehm/docker-provider/issues/new)._ + +### Initial setup + +_If you are trying things out from a Vagrant VM using the `Vagrantfile` gisted +above, you can skip to the next section_ + +The plugin requires Docker's executable to be available on current user's `PATH` +and that the current user has been added to the `docker` group since we are not +using `sudo` when interacting with Docker's CLI. For more information on setting +this up please check [this page](http://docs.docker.io/en/latest/installation/ubuntulinux/#giving-non-root-access). + +### `vagrant up` + +On its current state, the plugin is not "user friendly" and won't provide any kind +of feedback about the process of downloading Docker images, so before you add a +`docker-provider` [base box](http://docs.vagrantup.com/v2/boxes.html) it is recommended +that you `docker pull` the associated base box images prior to spinning up `docker-provider` +containers (otherwise you'll be staring at a blinking cursor without any progress +information for a while). + +Assuming you have Vagrant 1.4+ and Docker 0.7.0+ installed just sing that same +old song: + +```sh +vagrant plugin install docker-provider +docker pull fgrehm/vagrant-ubuntu:precise +vagrant box add precise64 http://bit.ly/vagrant-docker-precise +vagrant init precise64 +vagrant up --provider=docker +``` + +Under the hood, that base box will [configure](#configuration) `docker-provider` +to use the [`fgrehm/vagrant-ubuntu:precise`](https://index.docker.io/u/fgrehm/vagrant-ubuntu/) +image that approximates a standard Vagrant box (`vagrant` user, default SSH key, +etc.) and you should be good to go. + + +## Using custom images + +If you want to use a custom Docker image without creating a Vagrant base box, +you can use a "dummy" box and configure things from your `Vagrantfile` like +in [vagrant-digitalocean](https://github.com/smdahlen/vagrant-digitalocean#configure) +or [vagrant-aws](https://github.com/mitchellh/vagrant-aws#quick-start): + +```ruby +Vagrant.configure("2") do |config| + config.vm.box = "dummy" + config.vm.box_url = "http://bit.ly/vagrant-docker-dummy" + config.vm.provider :docker do |docker| + docker.image = "your/image:tag" + end +end +``` + + +## Configuration + +This provider exposes a few provider-specific configuration options +that are passed on to `docker run` under the hood when the container +is being created: + +* `image` - Docker image to run (required) +* `privileged` - Give extended privileges to the container (defaults to false) +* `cmd` - An array of strings that makes up for the command to run the container (defaults to what has been set on your `Dockerfile` as `CMD` or `ENTRYPOINT`) +* `ports` - An array of strings that makes up for the mapped network ports +* `volumes` - An array of strings that makes up for the data volumes used by the container + +These can be set like typical provider-specific configuration: + +```ruby +Vagrant.configure("2") do |config| + # ... other stuff + + config.vm.provider :docker do |docker| + docker.image = 'fgrehm/vagrant-ubuntu-dind:precise' + docker.privileged = true + docker.cmd = ['/dind', '/sbin/init'] + + docker.ports << '1234:22' + docker.volumes << '/var/lib/docker' + end +end +``` + + +## Networks + +Networking features in the form of `config.vm.network` are not supported with +`docker-provider` apart from [forwarded ports](). +If any of [`:private_network`](http://docs.vagrantup.com/v2/networking/private_network.html) +or [`:public_network`](http://docs.vagrantup.com/v2/networking/public_network.html) +are specified, Vagrant **won't** emit a warning. + +The same applies to changes on forwarded ports after the container has been +created, Vagrant **won't** emit a warning to let you know that the ports specified +on your `Vagrantfile` differs from what has been passed on to `docker run` when +creating the container. + +_At some point the plugin will emit warnings on the scenarios described above, but +not on its current state. Pull Requests are encouraged ;)_ + + +## Synced Folders + +There is support for synced folders on the form of [Docker volumes](http://docs.docker.io/en/latest/use/working_with_volumes/#mount-a-host-directory-as-a-container-volume) +but as with forwarded ports, you won't be able to change them after the container +has been created. [NFS](http://docs.vagrantup.com/v2/synced-folders/nfs.html) +synced folders are also supported (as long as you set the `privileged` +[config](#configuration) to true so that `docker-provider` can mount it on the +guest container) and are capable of being reconfigured between `vagrant reload`s +(different from Docker volumes). + +This is good enough for all built-in Vagrant provisioners (shell, +chef, and puppet) to work! + +_At some point the plugin will emit warnings when the configured `Vagrantfile` +synced folders / volumes differs from the ones used upon the container creation, +but not on its current state. Pull Requests are encouraged ;)_ + + +## Box format + +Every provider in Vagrant must introduce a custom box format. This provider introduces +`docker` boxes and you can view some examples in the [`boxes`](boxes) directory. +That directory also contains instructions on how to build them. + +The box format is basically just the required `metadata.json` file along with a +`Vagrantfile` that does default settings for the provider-specific configuration +for this provider. + + +## Available base boxes + +| LINK | DESCRIPTION | +| --- | --- | +| http://bit.ly/vagrant-docker-precise | Ubuntu 12.04 Precise x86_64 with Puppet and Chef preinstalled and configured to run `/sbin/init` | +| http://bit.ly/vagrant-docker-precise-dind | Ubuntu 12.04 Precise x86_64 based on the box above and ready to run [DinD](https://github.com/jpetazzo/dind) | + + +## Limitations + +As explained on the [networks](#networks) and [synced folder](#synced-folders) +sections above, there are some "gotchas" when using the plugin that you need to have +in mind before you start to pull your hair out. + +For instance, forwarded ports, synced folders and containers' hostnames will not be +reconfigured on `vagrant reload`s if they have changed and the plugin **_will not +give you any kind of warning or message_**. As an example, if you change your Puppet +manifests / Chef cookbooks paths (which are shared / synced folders under the hood), +**_you'll need to start from scratch_** (unless you make them NFS shared folders). +This is due to a limitation in Docker itself as we can't change those parameters +after the container has been created. + +Forwarded ports automatic collision handling is **_not supported as well_**. + + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/plugins/providers/docker/lib/action.rb b/plugins/providers/docker/lib/action.rb new file mode 100644 index 000000000..d09838c18 --- /dev/null +++ b/plugins/providers/docker/lib/action.rb @@ -0,0 +1,175 @@ +module VagrantPlugins + module DockerProvider + module Action + # Shortcuts + Builtin = Vagrant::Action::Builtin + Builder = Vagrant::Action::Builder + + # This action brings the "machine" up from nothing, including creating the + # container, configuring metadata, and booting. + def self.action_up + Builder.new.tap do |b| + b.use Builtin::ConfigValidate + b.use Builtin::Call, Created do |env, b2| + if !env[:result] + b2.use Builtin::HandleBoxUrl + # TODO: Find out where this fits into the process + # b2.use Builtin::EnvSet, :port_collision_repair => true + # b2.use Builtin::HandleForwardedPortCollisions + b2.use Builtin::Provision + b2.use PrepareNFSValidIds + b2.use Builtin::SyncedFolderCleanup + b2.use Builtin::SyncedFolders + b2.use PrepareNFSSettings + b2.use ForwardPorts + # This will actually create and start, but that's fine + b2.use Create + b2.use action_boot + else + b2.use PrepareNFSValidIds + b2.use Builtin::SyncedFolderCleanup + b2.use Builtin::SyncedFolders + b2.use PrepareNFSSettings + b2.use action_start + end + end + end + end + + # This action just runs the provisioners on the machine. + def self.action_provision + Builder.new.tap do |b| + b.use Builtin::ConfigValidate + b.use Builtin::Call, Created do |env1, b2| + if !env1[:result] + b2.use Message, :not_created + next + end + + b2.use Builtin::Call, IsRunning do |env2, b3| + if !env2[:result] + b3.use Message, :not_running + next + end + + b3.use Builtin::Provision + end + end + end + end + + # This is the action that is primarily responsible for halting + # the virtual machine, gracefully or by force. + def self.action_halt + Builder.new.tap do |b| + b.use Builtin::Call, Created do |env, b2| + if env[:result] + b2.use Builtin::Call, Builtin::GracefulHalt, :stopped, :running do |env2, b3| + if !env2[:result] + b3.use Stop + end + end + else + b2.use Message, :not_created + end + end + end + end + + # This action is responsible for reloading the machine, which + # brings it down, sucks in new configuration, and brings the + # machine back up with the new configuration. + def self.action_reload + Builder.new.tap do |b| + b.use Builtin::Call, Created do |env1, b2| + if !env1[:result] + b2.use Message, :not_created + next + end + + b2.use Builtin::ConfigValidate + b2.use action_halt + b2.use action_start + end + end + end + + # This is the action that is primarily responsible for completely + # freeing the resources of the underlying virtual machine. + def self.action_destroy + Builder.new.tap do |b| + b.use Builtin::Call, Created do |env1, b2| + if !env1[:result] + b2.use Message, :not_created + next + end + + b2.use Builtin::Call, Builtin::DestroyConfirm do |env2, b3| + if env2[:result] + b3.use Builtin::ConfigValidate + b3.use Builtin::EnvSet, :force_halt => true + b3.use action_halt + b3.use Destroy + b3.use Builtin::ProvisionerCleanup + else + b3.use Message, :will_not_destroy + end + end + end + end + end + + # This is the action that will exec into an SSH shell. + def self.action_ssh + Builder.new.tap do |b| + b.use CheckRunning + b.use Builtin::SSHExec + end + end + + # This is the action that will run a single SSH command. + def self.action_ssh_run + Builder.new.tap do |b| + b.use CheckRunning + b.use Builtin::SSHRun + end + end + + def self.action_start + Builder.new.tap do |b| + b.use Builtin::ConfigValidate + b.use Builtin::Call, IsRunning do |env, b2| + # If the container is running, then our work here is done, exit + next if env[:result] + + b2.use Builtin::Provision + b2.use Message, :starting + b2.use action_boot + end + end + end + + def self.action_boot + Builder.new.tap do |b| + # TODO: b.use Builtin::SetHostname + b.use Start + b.use Builtin::WaitForCommunicator + end + end + + # The autoload farm + action_root = Pathname.new(File.expand_path("../action", __FILE__)) + autoload :CheckRunning, action_root.join("check_running") + autoload :Created, action_root.join("created") + autoload :Create, action_root.join("create") + autoload :Destroy, action_root.join("destroy") + autoload :ForwardPorts, action_root.join("forward_ports") + autoload :Stop, action_root.join("stop") + autoload :Message, action_root.join("message") + autoload :PrepareNFSValidIds, action_root.join("prepare_nfs_valid_ids") + autoload :PrepareNFSSettings, action_root.join("prepare_nfs_settings") + autoload :IsRunning, action_root.join("is_running") + autoload :Start, action_root.join("start") + end + end +end diff --git a/plugins/providers/docker/lib/action/check_running.rb b/plugins/providers/docker/lib/action/check_running.rb new file mode 100644 index 000000000..75224096d --- /dev/null +++ b/plugins/providers/docker/lib/action/check_running.rb @@ -0,0 +1,25 @@ +module VagrantPlugins + module DockerProvider + module Action + class CheckRunning + def initialize(app, env) + @app = app + end + + def call(env) + if env[:machine].state.id == :not_created + raise Vagrant::Errors::VMNotCreatedError + end + + if env[:machine].state.id == :stopped + raise Vagrant::Errors::VMNotRunningError + end + + # Call the next if we have one (but we shouldn't, since this + # middleware is built to run with the Call-type middlewares) + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/create.rb b/plugins/providers/docker/lib/action/create.rb new file mode 100644 index 000000000..a8ea7fe6d --- /dev/null +++ b/plugins/providers/docker/lib/action/create.rb @@ -0,0 +1,59 @@ +module VagrantPlugins + module DockerProvider + module Action + class Create + def initialize(app, env) + @app = app + @@mutex ||= Mutex.new + end + + def call(env) + @env = env + @machine = env[:machine] + @provider_config = @machine.provider_config + @machine_config = @machine.config + @driver = @machine.provider.driver + + guard_cmd_configured! + + cid = '' + @@mutex.synchronize do + cid = @driver.create(create_params) + end + + @machine.id = cid + @app.call(env) + end + + def create_params + container_name = "#{@env[:root_path].basename.to_s}_#{@machine.name}" + container_name.gsub!(/[^-a-z0-9_]/i, "") + container_name << "_#{Time.now.to_i}" + + { + image: @provider_config.image, + cmd: @provider_config.cmd, + ports: forwarded_ports, + name: container_name, + hostname: @machine_config.vm.hostname, + volumes: @provider_config.volumes, + privileged: @provider_config.privileged + } + end + + def forwarded_ports + @env[:forwarded_ports].map do |fp| + # TODO: Support for the protocol argument + "#{fp[:host]}:#{fp[:guest]}" + end.compact + end + + def guard_cmd_configured! + if ! @provider_config.image + raise Errors::ImageNotConfiguredError, name: @machine.name + end + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/created.rb b/plugins/providers/docker/lib/action/created.rb new file mode 100644 index 000000000..39da1abdc --- /dev/null +++ b/plugins/providers/docker/lib/action/created.rb @@ -0,0 +1,18 @@ +module VagrantPlugins + module DockerProvider + module Action + class Created + def initialize(app, env) + @app = app + end + + def call(env) + machine = env[:machine] + driver = machine.provider.driver + env[:result] = machine.id && driver.created?(machine.id) + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/destroy.rb b/plugins/providers/docker/lib/action/destroy.rb new file mode 100644 index 000000000..aba1cbf18 --- /dev/null +++ b/plugins/providers/docker/lib/action/destroy.rb @@ -0,0 +1,24 @@ +module VagrantPlugins + module DockerProvider + module Action + class Destroy + def initialize(app, env) + @app = app + end + + def call(env) + env[:ui].info I18n.t("vagrant.actions.vm.destroy.destroying") + + machine = env[:machine] + config = machine.provider_config + driver = machine.provider.driver + + driver.rm(machine.id) + machine.id = nil + + @app.call env + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/forward_ports.rb b/plugins/providers/docker/lib/action/forward_ports.rb new file mode 100644 index 000000000..5051e7ab3 --- /dev/null +++ b/plugins/providers/docker/lib/action/forward_ports.rb @@ -0,0 +1,54 @@ +module VagrantPlugins + module DockerProvider + module Action + class ForwardPorts + def initialize(app, env) + @app = app + end + + def call(env) + @env = env + + env[:forwarded_ports] = compile_forwarded_ports(env[:machine].config) + + if env[:forwarded_ports].any? + env[:ui].info I18n.t("vagrant.actions.vm.forward_ports.forwarding") + inform_forwarded_ports(env[:forwarded_ports]) + end + + # FIXME: Check whether the container has already been created with + # different exposed ports and let the user know about it + + @app.call env + end + + def inform_forwarded_ports(ports) + ports.each do |fp| + message_attributes = { + :adapter => 'eth0', + :guest_port => fp[:guest], + :host_port => fp[:host] + } + + @env[:ui].info(I18n.t("vagrant.actions.vm.forward_ports.forwarding_entry", + message_attributes)) + end + end + + private + + def compile_forwarded_ports(config) + mappings = {} + + config.vm.networks.each do |type, options| + if type == :forwarded_port && options[:id] != 'ssh' + mappings[options[:host]] = options + end + end + + mappings.values + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/is_running.rb b/plugins/providers/docker/lib/action/is_running.rb new file mode 100644 index 000000000..3357bb7dd --- /dev/null +++ b/plugins/providers/docker/lib/action/is_running.rb @@ -0,0 +1,20 @@ +module VagrantPlugins + module DockerProvider + module Action + class IsRunning + def initialize(app, env) + @app = app + end + + def call(env) + machine = env[:machine] + driver = machine.provider.driver + + env[:result] = driver.running?(machine.id) + + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/message.rb b/plugins/providers/docker/lib/action/message.rb new file mode 100644 index 000000000..59a7bbfa7 --- /dev/null +++ b/plugins/providers/docker/lib/action/message.rb @@ -0,0 +1,23 @@ +module VagrantPlugins + module DockerProvider + module Action + # XXX: Is this really needed? Should we contribute this back to Vagrant's core? + class Message + def initialize(app, env, msg_key, type = :info) + @app = app + @msg_key = msg_key + @type = type + end + + def call(env) + machine = env[:machine] + message = I18n.t("docker_provider.messages.#{@msg_key}", name: machine.name) + + env[:ui].send @type, message + + @app.call env + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/prepare_nfs_settings.rb b/plugins/providers/docker/lib/action/prepare_nfs_settings.rb new file mode 100644 index 000000000..5d437f472 --- /dev/null +++ b/plugins/providers/docker/lib/action/prepare_nfs_settings.rb @@ -0,0 +1,59 @@ +require_relative '../errors' + +module VagrantPlugins + module DockerProvider + module Action + class PrepareNFSSettings + include Vagrant::Util::Retryable + + def initialize(app, env) + @app = app + @logger = Log4r::Logger.new("vagrant::action::vm::nfs") + end + + def call(env) + @machine = env[:machine] + + @app.call(env) + + if using_nfs? && !privileged_container? + raise Errors::NfsWithoutPrivilegedError + end + + if using_nfs? + @logger.info("Using NFS, preparing NFS settings by reading host IP and machine IP") + add_ips_to_env!(env) + end + end + + # We're using NFS if we have any synced folder with NFS configured. If + # we are not using NFS we don't need to do the extra work to + # populate these fields in the environment. + def using_nfs? + @machine.config.vm.synced_folders.any? { |_, opts| opts[:type] == :nfs } + end + + def privileged_container? + @machine.provider.driver.privileged?(@machine.id) + end + + # Extracts the proper host and guest IPs for NFS mounts and stores them + # in the environment for the SyncedFolder action to use them in + # mounting. + # + # The ! indicates that this method modifies its argument. + def add_ips_to_env!(env) + provider = env[:machine].provider + + host_ip = provider.driver.docker_bridge_ip + machine_ip = provider.ssh_info[:host] + + raise Vagrant::Errors::NFSNoHostonlyNetwork if !host_ip || !machine_ip + + env[:nfs_host_ip] = host_ip + env[:nfs_machine_ip] = machine_ip + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/prepare_nfs_valid_ids.rb b/plugins/providers/docker/lib/action/prepare_nfs_valid_ids.rb new file mode 100644 index 000000000..6bc79f3b3 --- /dev/null +++ b/plugins/providers/docker/lib/action/prepare_nfs_valid_ids.rb @@ -0,0 +1,19 @@ +module VagrantPlugins + module DockerProvider + module Action + class PrepareNFSValidIds + def initialize(app, env) + @app = app + @logger = Log4r::Logger.new("vagrant::action::vm::nfs") + end + + def call(env) + machine = env[:machine] + env[:nfs_valid_ids] = machine.provider.driver.all_containers + + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/start.rb b/plugins/providers/docker/lib/action/start.rb new file mode 100644 index 000000000..e748adff6 --- /dev/null +++ b/plugins/providers/docker/lib/action/start.rb @@ -0,0 +1,18 @@ +module VagrantPlugins + module DockerProvider + module Action + class Start + def initialize(app, env) + @app = app + end + + def call(env) + machine = env[:machine] + driver = machine.provider.driver + driver.start(machine.id) + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/action/stop.rb b/plugins/providers/docker/lib/action/stop.rb new file mode 100644 index 000000000..f743c8b6c --- /dev/null +++ b/plugins/providers/docker/lib/action/stop.rb @@ -0,0 +1,21 @@ +module VagrantPlugins + module DockerProvider + module Action + class Stop + def initialize(app, env) + @app = app + end + + def call(env) + machine = env[:machine] + driver = machine.provider.driver + if driver.running?(machine.id) + env[:ui].info I18n.t("docker_provider.messages.stopping") + driver.stop(machine.id) + end + @app.call(env) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/config.rb b/plugins/providers/docker/lib/config.rb new file mode 100644 index 000000000..2b2c7198e --- /dev/null +++ b/plugins/providers/docker/lib/config.rb @@ -0,0 +1,35 @@ +module VagrantPlugins + module DockerProvider + class Config < Vagrant.plugin("2", :config) + attr_accessor :image, :cmd, :ports, :volumes, :privileged + + def initialize + @image = nil + @cmd = UNSET_VALUE + @ports = [] + @privileged = UNSET_VALUE + @volumes = [] + end + + def finalize! + @cmd = [] if @cmd == UNSET_VALUE + @privileged = false if @privileged == UNSET_VALUE + end + + def validate(machine) + errors = _detected_errors + + # TODO: Detect if base image has a CMD / ENTRYPOINT set before erroring out + errors << I18n.t("docker_provider.errors.config.cmd_not_set") if @cmd == UNSET_VALUE + + { "docker-provider" => errors } + end + + private + + def using_nfs?(machine) + machine.config.vm.synced_folders.any? { |_, opts| opts[:type] == :nfs } + end + end + end +end diff --git a/plugins/providers/docker/lib/driver.rb b/plugins/providers/docker/lib/driver.rb new file mode 100644 index 000000000..9a4a39e50 --- /dev/null +++ b/plugins/providers/docker/lib/driver.rb @@ -0,0 +1,138 @@ +require "vagrant/util/busy" +require "vagrant/util/subprocess" +require "vagrant/util/retryable" + +require 'log4r' +require 'json' + +module VagrantPlugins + module DockerProvider + class Driver + include Vagrant::Util::Retryable + + def initialize + @logger = Log4r::Logger.new("vagrant::docker::driver") + end + + def create(params) + image = params.fetch(:image) + ports = Array(params[:ports]) + volumes = Array(params[:volumes]) + name = params.fetch(:name) + cmd = Array(params.fetch(:cmd)) + + run_cmd = %W(docker run -name #{name} -d) + run_cmd += ports.map { |p| ['-p', p.to_s] } + run_cmd += volumes.map { |v| ['-v', v.to_s] } + run_cmd += %W(-privileged) if params[:privileged] + run_cmd += %W(-h #{params[:hostname]}) if params[:hostname] + run_cmd += [image, cmd] + + retryable(tries: 10, sleep: 1) do + execute(*run_cmd.flatten).chomp + end + end + + def state(cid) + case + when running?(cid) + :running + when created?(cid) + :stopped + else + :not_created + end + end + + def created?(cid) + result = execute('docker', 'ps', '-a', '-q', '-notrunc').to_s + result =~ /^#{Regexp.escape cid}$/ + end + + def running?(cid) + result = execute('docker', 'ps', '-q', '-notrunc') + result =~ /^#{Regexp.escape cid}$/m + end + + def privileged?(cid) + inspect_container(cid)['HostConfig']['Privileged'] + end + + def start(cid) + unless running?(cid) + execute('docker', 'start', cid) + # This resets the cached information we have around, allowing `vagrant reload`s + # to work properly + # TODO: Add spec to verify this behavior + @data = nil + end + end + + def stop(cid) + if running?(cid) + execute('docker', 'stop', '-t', '1', cid) + end + end + + def rm(cid) + if created?(cid) + execute('docker', 'rm', '-v', cid) + end + end + + def inspect_container(cid) + # DISCUSS: Is there a chance that this json will change after the container + # has been brought up? + @data ||= JSON.parse(execute('docker', 'inspect', cid)).first + end + + def all_containers + execute('docker', 'ps', '-a', '-q', '-notrunc').to_s.split + end + + def docker_bridge_ip + output = execute('/sbin/ip', '-4', 'addr', 'show', 'scope', 'global', 'docker0') + if output =~ /^\s+inet ([0-9.]+)\/[0-9]+\s+/ + return $1.to_s + else + # TODO: Raise an user friendly message + raise 'Unable to fetch docker bridge IP!' + end + end + + private + + def execute(*cmd, &block) + result = raw(*cmd, &block) + + if result.exit_code != 0 + if @interrupted + @logger.info("Exit code != 0, but interrupted. Ignoring.") + else + msg = result.stdout.gsub("\r\n", "\n") + msg << result.stderr.gsub("\r\n", "\n") + raise "#{cmd.inspect}\n#{msg}" #Errors::ExecuteError, :command => command.inspect + end + end + + # Return the output, making sure to replace any Windows-style + # newlines with Unix-style. + result.stdout.gsub("\r\n", "\n") + end + + def raw(*cmd, &block) + int_callback = lambda do + @interrupted = true + @logger.info("Interrupted.") + end + + # Append in the options for subprocess + cmd << { :notify => [:stdout, :stderr] } + + Vagrant::Util::Busy.busy(int_callback) do + Vagrant::Util::Subprocess.execute(*cmd, &block) + end + end + end + end +end diff --git a/plugins/providers/docker/lib/errors.rb b/plugins/providers/docker/lib/errors.rb new file mode 100644 index 000000000..68b3fbd71 --- /dev/null +++ b/plugins/providers/docker/lib/errors.rb @@ -0,0 +1,14 @@ +require 'vagrant/errors' + +module VagrantPlugins + module DockerProvider + module Errors + class ImageNotConfiguredError < Vagrant::Errors::VagrantError + error_key(:docker_provider_image_not_configured) + end + class NfsWithoutPrivilegedError < Vagrant::Errors::VagrantError + error_key(:docker_provider_nfs_without_privileged) + end + end + end +end diff --git a/plugins/providers/docker/lib/plugin.rb b/plugins/providers/docker/lib/plugin.rb new file mode 100644 index 000000000..abe8ae6c2 --- /dev/null +++ b/plugins/providers/docker/lib/plugin.rb @@ -0,0 +1,31 @@ +# TODO: Switch to Vagrant.require_version before 1.0.0 +# see: https://github.com/mitchellh/vagrant/blob/bc55081e9ffaa6820113e449a9f76b293a29b27d/lib/vagrant.rb#L202-L228 +unless Gem::Requirement.new('>= 1.4.0').satisfied_by?(Gem::Version.new(Vagrant::VERSION)) + raise 'docker-provider requires Vagrant >= 1.4.0 in order to work!' +end + +I18n.load_path << File.expand_path(File.dirname(__FILE__) + '/../../locales/en.yml') +I18n.reload! + +module VagrantPlugins + module DockerProvider + class Plugin < Vagrant.plugin("2") + name "docker-provider" + + provider(:docker, parallel: true) do + require_relative 'provider' + Provider + end + + config(:docker, :provider) do + require_relative 'config' + Config + end + + synced_folder(:docker) do + require File.expand_path("../synced_folder", __FILE__) + SyncedFolder + end + end + end +end diff --git a/plugins/providers/docker/lib/provider.rb b/plugins/providers/docker/lib/provider.rb new file mode 100644 index 000000000..fcc48230f --- /dev/null +++ b/plugins/providers/docker/lib/provider.rb @@ -0,0 +1,61 @@ +require "log4r" + +require_relative 'driver' +require_relative 'action' + +module VagrantPlugins + module DockerProvider + class Provider < Vagrant.plugin("2", :provider) + attr_reader :driver + + def initialize(machine) + @logger = Log4r::Logger.new("vagrant::provider::docker") + @machine = machine + @driver = Driver.new + end + + # @see Vagrant::Plugin::V2::Provider#action + def action(name) + action_method = "action_#{name}" + return Action.send(action_method) if Action.respond_to?(action_method) + nil + end + + # Returns the SSH info for accessing the Container. + def ssh_info + # If the Container is not created then we cannot possibly SSH into it, so + # we return nil. + return nil if state == :not_created + + network = @driver.inspect_container(@machine.id)['NetworkSettings'] + ip = network['IPAddress'] + + # If we were not able to identify the container's IP, we return nil + # here and we let Vagrant core deal with it ;) + return nil unless ip + + { + :host => ip, + :port => @machine.config.ssh.guest_port + } + end + + def state + state_id = nil + state_id = :not_created if !@machine.id || !@driver.created?(@machine.id) + state_id = @driver.state(@machine.id) if @machine.id && !state_id + state_id = :unknown if !state_id + + short = state_id.to_s.gsub("_", " ") + long = I18n.t("vagrant.commands.status.#{state_id}") + + Vagrant::MachineState.new(state_id, short, long) + end + + def to_s + id = @machine.id ? @machine.id : "new container" + "Docker (#{id})" + end + end + end +end diff --git a/plugins/providers/docker/lib/synced_folder.rb b/plugins/providers/docker/lib/synced_folder.rb new file mode 100644 index 000000000..9aad24761 --- /dev/null +++ b/plugins/providers/docker/lib/synced_folder.rb @@ -0,0 +1,20 @@ +module VagrantPlugins + module DockerProvider + class SyncedFolder < Vagrant.plugin("2", :synced_folder) + def usable?(machine) + # These synced folders only work if the provider is Docker + machine.provider_name == :docker + end + + def prepare(machine, folders, _opts) + # FIXME: Check whether the container has already been created with + # different synced folders and let the user know about it + folders.each do |id, data| + host_path = File.expand_path(data[:hostpath], machine.env.root_path) + guest_path = data[:guestpath] + machine.provider_config.volumes << "#{host_path}:#{guest_path}" + end + end + end + end +end diff --git a/templates/locales/providers_docker.yml b/templates/locales/providers_docker.yml new file mode 100644 index 000000000..04db785eb --- /dev/null +++ b/templates/locales/providers_docker.yml @@ -0,0 +1,32 @@ +en: + docker_provider: + messages: + not_created: |- + The container hasn't been created yet. + not_running: |- + The container is not currently running. + will_not_destroy: |- + The container '%{name}' will not be destroyed, since the confirmation + was declined. + starting: |- + Starting container... + stopping: |- + Stopping container... + container_ready: |- + Container started and ready for use! + + errors: + config: + cmd_not_set: |- + The Docker command has not been set! + + vagrant: + errors: + docker_provider_nfs_without_privileged: |- + You've configured a NFS synced folder but didn't enable privileged + mode for the container. Please set the `privileged` option to true + on the provider block from your Vagrantfile, recreate the container + and try again. + + docker_provider_image_not_configured: |- + The base Docker image has not been set for the '%{name}' VM! diff --git a/test/unit/plugins/providers/docker/driver_spec.rb b/test/unit/plugins/providers/docker/driver_spec.rb new file mode 100644 index 000000000..b057d0d0e --- /dev/null +++ b/test/unit/plugins/providers/docker/driver_spec.rb @@ -0,0 +1,202 @@ +require_relative "../../../base" + +require Vagrant.source_root.join("plugins/providers/docker/driver") + +describe VagrantPlugins::DockerProvider::Driver do + let(:cmd_executed) { @cmd } + let(:cid) { 'side-1-song-10' } + + before do + subject.stub(:execute) { |*args| @cmd = args.join(' ') } + end + + describe '#create' do + let(:params) { { + image: 'jimi/hendrix:eletric-ladyland', + cmd: ['play', 'voodoo-chile'], + ports: '8080:80', + volumes: '/host/path:guest/path', + name: cid, + hostname: 'jimi-hendrix', + privileged: true + } } + + before { subject.create(params) } + + it 'runs a detached docker image' do + expect(cmd_executed).to match(/^docker run .+ -d .+ #{Regexp.escape params[:image]}/) + end + + it 'sets container name' do + expect(cmd_executed).to match(/-name #{Regexp.escape params[:name]}/) + end + + it 'forwards ports' do + expect(cmd_executed).to match(/-p #{params[:ports]} .+ #{Regexp.escape params[:image]}/) + end + + it 'shares folders' do + expect(cmd_executed).to match(/-v #{params[:volumes]} .+ #{Regexp.escape params[:image]}/) + end + + it 'is able to run a privileged container' do + expect(cmd_executed).to match(/-privileged .+ #{Regexp.escape params[:image]}/) + end + + it 'sets the hostname if specified' do + expect(cmd_executed).to match(/-h #{params[:hostname]} #{Regexp.escape params[:image]}/) + end + + it 'executes the provided command' do + expect(cmd_executed).to match(/#{Regexp.escape params[:image]} #{Regexp.escape params[:cmd].join(' ')}/) + end + end + + describe '#created?' do + let(:result) { subject.created?(cid) } + + it 'performs the check on all containers list' do + subject.created?(cid) + expect(cmd_executed).to match(/docker ps \-a \-q/) + end + + context 'when container exists' do + before { subject.stub(execute: "foo\n#{cid}\nbar") } + it { expect(result).to be_true } + end + + context 'when container does not exist' do + before { subject.stub(execute: "foo\n#{cid}extra\nbar") } + it { expect(result).to be_false } + end + end + + describe '#running?' do + let(:result) { subject.running?(cid) } + + it 'performs the check on the running containers list' do + subject.running?(cid) + expect(cmd_executed).to match(/docker ps \-q/) + expect(cmd_executed).to_not include('-a') + end + + context 'when container exists' do + before { subject.stub(execute: "foo\n#{cid}\nbar") } + it { expect(result).to be_true } + end + + context 'when container does not exist' do + before { subject.stub(execute: "foo\n#{cid}extra\nbar") } + it { expect(result).to be_false } + end + end + + describe '#privileged?' do + it 'identifies privileged containers' do + subject.stub(inspect_container: {'HostConfig' => {"Privileged" => true}}) + expect(subject).to be_privileged(cid) + end + + it 'identifies unprivileged containers' do + subject.stub(inspect_container: {'HostConfig' => {"Privileged" => false}}) + expect(subject).to_not be_privileged(cid) + end + end + + describe '#start' do + context 'when container is running' do + before { subject.stub(running?: true) } + + it 'does not start the container' do + subject.should_not_receive(:execute).with('docker', 'start', cid) + subject.start(cid) + end + end + + context 'when container is not running' do + before { subject.stub(running?: false) } + + it 'starts the container' do + subject.should_receive(:execute).with('docker', 'start', cid) + subject.start(cid) + end + end + end + + describe '#stop' do + context 'when container is running' do + before { subject.stub(running?: true) } + + it 'stops the container' do + subject.should_receive(:execute).with('docker', 'stop', '-t', '1', cid) + subject.stop(cid) + end + end + + context 'when container is not running' do + before { subject.stub(running?: false) } + + it 'does not stop container' do + subject.should_not_receive(:execute).with('docker', 'stop', '-t', '1', cid) + subject.stop(cid) + end + end + end + + describe '#rm' do + context 'when container has been created' do + before { subject.stub(created?: true) } + + it 'removes the container' do + subject.should_receive(:execute).with('docker', 'rm', '-v', cid) + subject.rm(cid) + end + end + + context 'when container has not been created' do + before { subject.stub(created?: false) } + + it 'does not attempt to remove the container' do + subject.should_not_receive(:execute).with('docker', 'rm', '-v', cid) + subject.rm(cid) + end + end + end + + describe '#inspect_container' do + let(:data) { '[{"json": "value"}]' } + + before { subject.stub(execute: data) } + + it 'inspects the container' do + subject.should_receive(:execute).with('docker', 'inspect', cid) + subject.inspect_container(cid) + end + + it 'parses the json output' do + expect(subject.inspect_container(cid)).to eq('json' => 'value') + end + end + + describe '#all_containers' do + let(:containers) { "container1\ncontainer2" } + + before { subject.stub(execute: containers) } + + it 'returns an array of all known containers' do + subject.should_receive(:execute).with('docker', 'ps', '-a', '-q', '-notrunc') + expect(subject.all_containers).to eq(['container1', 'container2']) + end + end + + describe '#docker_bridge_ip' do + let(:containers) { " inet 123.456.789.012/16 " } + + before { subject.stub(execute: containers) } + + it 'returns an array of all known containers' do + subject.should_receive(:execute).with('/sbin/ip', '-4', 'addr', 'show', 'scope', 'global', 'docker0') + expect(subject.docker_bridge_ip).to eq('123.456.789.012') + end + end +end