diff --git a/.gitignore b/.gitignore index 8d9bfa46..9a33d841 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tmp .kitchen/ .kitchen.local.yml Dockerfile* +.DS_Store diff --git a/.kitchen.windows.yml b/.kitchen.windows.yml new file mode 100644 index 00000000..a8d09aa7 --- /dev/null +++ b/.kitchen.windows.yml @@ -0,0 +1,33 @@ +<% # Make sure the local copy of the driver is loaded %> +<% lib = File.expand_path('../lib', __FILE__) %> +<% $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) %> +--- +driver: + name: docker + provision_command: + - powershell -ExecutionPolicy Bypass -NoLogo -Command . { iwr -useb https://omnitruck.chef.io/install.ps1 } ^| iex; install + - powershell -Command $path=$env:Path + ';c:\opscode\chef\embedded\bin'; Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment\' -Name Path -Value $path + +transport: + name: docker + socket: tcp://localhost:2375 + +provisioner: + name: dummy + +platforms: +- name: windows + driver_config: + image: mcr.microsoft.com/windows/servercore:1803 + platform: windows + +suites: +- name: default +- name: context + driver: + build_context: false +- name: inspec + driver: + provision_command: echo 1 + verifier: + name: inspec diff --git a/.kitchen.yml b/.kitchen.yml index 77488aad..b07f2323 100644 --- a/.kitchen.yml +++ b/.kitchen.yml @@ -6,6 +6,9 @@ driver: name: docker provision_command: curl -L https://www.chef.io/chef/install.sh | bash +transport: + name: docker + provisioner: name: dummy @@ -14,6 +17,10 @@ platforms: - name: ubuntu-16.04 - name: ubuntu-18.04 - name: fedora-latest + driver: + provision_command: + - yum install libxcrypt-compat.x86_64 -y + - curl -L https://www.chef.io/chef/install.sh | bash - name: centos-6 - name: centos-7 - name: oraclelinux-6 diff --git a/.travis.yml b/.travis.yml index 84bf0fb0..605ac2ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,52 @@ -dist: xenial -language: ruby -cache: bundler - -rvm: -- 2.3.8 -- 2.4.5 -- 2.5.4 -- 2.6.2 +matrix: + include: + - os: linux + rvm: 2.4.5 + dist: xenial + language: ruby + cache: bundler + script: + - bundle exec docker version + - bundle exec kitchen --version + - bundle exec rake spec + - bundle exec kitchen test -d always + - os: linux + rvm: 2.5.4 + dist: xenial + language: ruby + cache: bundler + script: + - bundle exec docker version + - bundle exec kitchen --version + - bundle exec rake spec + - bundle exec kitchen test -d always + - os: linux + rvm: 2.6.2 + dist: xenial + language: ruby + cache: bundler + script: + - bundle exec docker version + - bundle exec kitchen --version + - bundle exec rake spec + - bundle exec kitchen test -d always + - os: windows + language: bash + install: + - choco install mingw + - choco install msys2 + - ridk.cmd exec pacman -S --noconfirm --needed base-devel mingw-w64-x86_64-toolchain + script: + - taskkill -IM "gpg-agent.exe" -F + - powershell -ExecutionPolicy Bypass -NoLogo -File docker.ps1 + - export KITCHEN_YAML=.kitchen.windows.yml + - ruby -v + - gem install bundler + - bundle install + - bundle exec docker version + - bundle exec kitchen --version + - bundle exec rake spec + - bundle exec kitchen test -d always services: - docker - -script: -- bundle exec docker version -- bundle exec kitchen --version -- bundle exec rake spec -- bundle exec kitchen test -d always diff --git a/README.md b/README.md index b0b29fca..ad211487 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage](https://img.shields.io/codecov/c/github/test-kitchen/kitchen-docker.svg)](https://codecov.io/github/test-kitchen/kitchen-docker) [![License](https://img.shields.io/badge/license-Apache_2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -A Test Kitchen Driver for Docker. +A Test Kitchen Driver and Transport for Docker. ## Requirements @@ -15,13 +15,15 @@ A Test Kitchen Driver for Docker. Please read the Test Kitchen [docs][test_kitchen_docs] for more details. -Example `.kitchen.local.yml`: +Example (Linux) `.kitchen.local.yml`: ```yaml --- driver: name: docker - + env_variables: + TEST_KEY: TEST_VALUE + platforms: - name: ubuntu run_list: @@ -32,6 +34,30 @@ platforms: platform: rhel run_list: - recipe[yum] + +transport: + name: docker +``` + +Example (Windows) `.kitchen.local.yml`: + +```yaml +--- +driver: + name: docker + +platforms: +- name: windows + driver_config: + image: mcr.microsoft.com/windows/servercore:1607 + platform: windows + run_list: + - recipe[chef_client] + +transport: + name: docker + env_variables: + TEST_KEY: TEST_VALUE ``` ## Default Configuration @@ -83,11 +109,9 @@ Examples: ### socket -The Docker daemon socket to use. By default, Docker will listen on -`unix:///var/run/docker.sock`, and no configuration here is required. If -Docker is binding to another host/port or Unix socket, you will need to set -this option. If a TCP socket is set, its host will be used for SSH access -to suite containers. +The Docker daemon socket to use. By default, Docker will listen on `unix:///var/run/docker.sock` (On Windows, `npipe:////./pipe/docker_engine`), +and no configuration here is required. If Docker is binding to another host/port or Unix socket, you will need to set this option. +If a TCP socket is set, its host will be used for SSH access to suite containers. Examples: @@ -99,10 +123,27 @@ Examples: socket: tcp://docker.example.com:4242 ``` -If you use [Docker for Windows](https://docs.docker.com/docker-for-windows/) - +If you are using the InSpec verifier on Windows, using named pipes for the Docker engine will not work with the Docker transport. +Set the socket option with the TCP socket address of the Docker engine as shown below: ```yaml -socket: npipe:////./pipe/docker_engine +socket: tcp://localhost:2375 +``` + +The Docker engine must be configured to listen on a TCP port (default port is 2375). This can be configured by editing the configuration file +(usually located in `C:\ProgramData\docker\config\daemon.json`) and adding the hosts value: +``` +"hosts": ["tcp://0.0.0.0:2375"] +``` + +Example configuration is shown below: +``` +{ + "registry-mirrors": [], + "insecure-registries": [], + "debug": true, + "experimental": false, + "hosts": ["tcp://0.0.0.0:2375"] +} ``` If you use [Boot2Docker](https://github.com/boot2docker/boot2docker) @@ -115,7 +156,6 @@ $MACHINE)"` then use the following: socket: tcp://192.168.59.103:2375 ``` - ### image The Docker image to use as the base for the suite containers. You can find @@ -134,6 +174,7 @@ suite container for Test Kitchen. Kitchen Docker currently supports: * `amazonlinux`, `rhel`, `centos`, `fedora` or `oraclelinux` * `gentoo` or `gentoo-paludis` * `opensuse/tumbleweed`, `opensuse/leap`, `opensuse` or `sles` +* `windows` The default will be computed, using the platform name (see the Default Configuration section for more details). @@ -182,6 +223,17 @@ driver_config: provision_command: curl -L https://www.opscode.com/chef/install.sh | bash require_chef_omnibus: false ``` +### env_variables + +Adds environment variables to Docker container + +Examples: + +```yaml + env_variables: + TEST_KEY_1: TEST_VALUE + SOME_VAR: SOME_VALUE +``` ### use\_cache diff --git a/docker.ps1 b/docker.ps1 new file mode 100644 index 00000000..8c67e2e7 --- /dev/null +++ b/docker.ps1 @@ -0,0 +1,9 @@ +# This script is used to configure the Docker service for Windows builds in Travis CI +Write-Host "Configuring Docker service to listen on TCP port 2375..." +$dockerSvcArgs = (Get-WmiObject Win32_Service | ?{$_.Name -eq 'docker'} | Select PathName).PathName +$dockerSvcArgs = "$dockerSvcArgs -H tcp://0.0.0.0:2375 -H npipe:////./pipe/docker_engine" +Write-Host "Docker Service Args: $dockerSvcArgs" + +Get-WmiObject Win32_Service -Filter "Name='docker'" | Invoke-WmiMethod -Name Change -ArgumentList @($null,$null,$null,$null,$null, $dockerSvcArgs) | Out-Null + +Restart-Service docker -Force -Verbose diff --git a/kitchen-docker.gemspec b/kitchen-docker.gemspec index 13a3fecf..13cc9482 100644 --- a/kitchen-docker.gemspec +++ b/kitchen-docker.gemspec @@ -1,11 +1,10 @@ -# coding: utf-8 lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'kitchen/driver/docker_version' +require 'kitchen/docker/docker_version' Gem::Specification.new do |spec| spec.name = 'kitchen-docker' - spec.version = Kitchen::Driver::DOCKER_VERSION + spec.version = Kitchen::Docker::DOCKER_VERSION spec.authors = ['Sean Porter'] spec.email = ['portertech@gmail.com'] spec.description = %q{A Docker Driver for Test Kitchen} @@ -36,5 +35,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'codecov', '~> 0.0', '>= 0.0.2' # Integration testing gems. - spec.add_development_dependency 'kitchen-inspec', '~> 0.14' + spec.add_development_dependency 'kitchen-inspec', '~> 1.1' + spec.add_development_dependency 'train', '~> 2.1' end diff --git a/lib/docker/version.rb b/lib/docker/version.rb new file mode 100644 index 00000000..70fd25ef --- /dev/null +++ b/lib/docker/version.rb @@ -0,0 +1,25 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +begin + require 'docker' + + # Override API_VERSION constant in docker-api gem to use version 1.24 of the Docker API + # This override is for the docker-api gem to communicate to the Docker engine on Windows + module Docker + VERSION = '0.0.0' + API_VERSION = '1.24' + end +rescue LoadError => e + logger.debug("[Docker] docker-api gem not found for InSpec verifier. #{e}") +end diff --git a/lib/kitchen/docker/container.rb b/lib/kitchen/docker/container.rb new file mode 100644 index 00000000..b4ac2490 --- /dev/null +++ b/lib/kitchen/docker/container.rb @@ -0,0 +1,70 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'kitchen/docker/helpers/cli_helper' +require 'kitchen/docker/helpers/container_helper' +require 'kitchen/docker/helpers/file_helper' +require 'kitchen/docker/helpers/image_helper' + +module Kitchen + module Docker + class Container + include Kitchen::Docker::Helpers::CliHelper + include Kitchen::Docker::Helpers::ContainerHelper + include Kitchen::Docker::Helpers::FileHelper + include Kitchen::Docker::Helpers::ImageHelper + + def initialize(config) + @config = config + end + + def create(state) + if container_exists?(state) + info("Container ID #{state[:container_id]} already exists.") + elsif !container_exists?(state) && state[:container_id] + raise ActionFailed, "Container ID #{state[:container_id]} was found in the kitchen state data, "\ + 'but the container does not exist.' + end + + state[:username] = @config[:username] + state[:hostname] = 'localhost' + + if remote_socket? + state[:hostname] = socket_uri.host + elsif config[:use_internal_docker_network] + state[:hostname] = container_ip_address(state) + end + end + + def upload(locals, remote) + files = locals + files = Array(locals) unless locals.is_a?(Array) + + files.each do |file| + copy_file_to_container(@config, file, remote) + end + + files + end + + def destroy(state) + info("[Docker] Destroying Docker container #{state[:container_id]}") if state[:container_id] + remove_container(state) if container_exists?(state) + + if @config[:remove_images] && state[:image_id] + remove_image(state) if image_exists?(state) + end + end + end + end +end diff --git a/lib/kitchen/docker/container/linux.rb b/lib/kitchen/docker/container/linux.rb new file mode 100644 index 00000000..ddea5903 --- /dev/null +++ b/lib/kitchen/docker/container/linux.rb @@ -0,0 +1,211 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'base64' +require 'openssl' +require 'securerandom' +require 'shellwords' + +require 'kitchen/docker/container' + +module Kitchen + module Docker + class Container + class Linux < Kitchen::Docker::Container + MUTEX_FOR_SSH_KEYS = Mutex.new + + def initialize(config) + super + end + + def create(state) + super + + debug('Creating Linux container') + generate_keys + + state[:ssh_key] = @config[:private_key] + state[:image_id] = build_image(state, dockerfile) unless state[:image_id] + state[:container_id] = run_container(state, 22) unless state[:container_id] + state[:hostname] = 'localhost' + state[:port] = container_ssh_port(state) + end + + def execute(command) + # Create temp script file and upload files to container + debug("Executing command on Linux container (Platform: #{@config[:platform]})") + filename = "docker-#{::SecureRandom.uuid}.sh" + temp_file = "./.kitchen/temp/#{filename}" + create_temp_file(temp_file, command) + + remote_path = @config[:temp_dir] + debug("Creating directory #{remote_path} on container") + create_dir_on_container(@config, remote_path) + + debug("Uploading temp file #{temp_file} to #{remote_path} on container") + upload(temp_file, remote_path) + + debug('Deleting temp file from local filesystem') + ::File.delete(temp_file) + + # Replace any environment variables used in the path and execute script file + debug("Executing temp script #{remote_path}/#{filename} on container") + remote_path = replace_env_variables(@config, remote_path) + + container_exec(@config, "/bin/bash #{remote_path}/#{filename}") + rescue => e + raise "Failed to execute command on Linux container. #{e}" + end + + protected + + def generate_keys + MUTEX_FOR_SSH_KEYS.synchronize do + if !File.exist?(@config[:public_key]) || !File.exist?(@config[:private_key]) + private_key = OpenSSL::PKey::RSA.new(2048) + blobbed_key = Base64.encode64(private_key.to_blob).gsub("\n", '') + public_key = "ssh-rsa #{blobbed_key} kitchen_docker_key" + File.open(@config[:private_key], 'w') do |file| + file.write(private_key) + file.chmod(0600) + end + File.open(@config[:public_key], 'w') do |file| + file.write(public_key) + file.chmod(0600) + end + end + end + end + + def parse_container_ssh_port(output) + _host, port = output.split(':') + port.to_i + rescue => e + raise ActionFailed, "Could not parse Docker port output for container SSH port. #{e}" + end + + def container_ssh_port(state) + return 22 if @config[:use_internal_docker_network] + + output = docker_command("port #{state[:container_id]} 22/tcp") + parse_container_ssh_port(output) + rescue => e + raise ActionFailed, "Docker reports container has no ssh port mapped. #{e}" + end + + def dockerfile + return dockerfile_template if @config[:dockerfile] + + from = "FROM #{@config[:image]}" + + platform = case @config[:platform] + when 'debian', 'ubuntu' + disable_upstart = <<-CODE + RUN [ ! -f "/sbin/initctl" ] || dpkg-divert --local --rename --add /sbin/initctl && ln -sf /bin/true /sbin/initctl + CODE + packages = <<-CODE + ENV DEBIAN_FRONTEND noninteractive + ENV container docker + RUN apt-get update + RUN apt-get install -y sudo openssh-server curl lsb-release + CODE + @config[:disable_upstart] ? disable_upstart + packages : packages + when 'rhel', 'centos', 'oraclelinux', 'amazonlinux' + <<-CODE + ENV container docker + RUN yum clean all + RUN yum install -y sudo openssh-server openssh-clients which curl + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' + CODE + when 'fedora' + <<-CODE + ENV container docker + RUN dnf clean all + RUN dnf install -y sudo openssh-server openssh-clients which curl + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' + CODE + when 'opensuse/tumbleweed', 'opensuse/leap', 'opensuse', 'sles' + <<-CODE + ENV container docker + RUN zypper install -y sudo openssh which curl + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' + CODE + when 'arch' + # See https://bugs.archlinux.org/task/47052 for why we + # blank out limits.conf. + <<-CODE + RUN pacman --noconfirm -Sy archlinux-keyring + RUN pacman-db-upgrade + RUN pacman --noconfirm -Syu openssl openssh sudo curl + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key + RUN echo >/etc/security/limits.conf + CODE + when 'gentoo' + <<-CODE + RUN emerge --sync + RUN emerge net-misc/openssh app-admin/sudo + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key + CODE + when 'gentoo-paludis' + <<-CODE + RUN cave sync + RUN cave resolve -zx net-misc/openssh app-admin/sudo + RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key + RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key + CODE + else + raise ActionFailed, "Unknown platform '#{@config[:platform]}'" + end + + username = @config[:username] + public_key = IO.read(@config[:public_key]).strip + homedir = username == 'root' ? '/root' : "/home/#{username}" + + base = <<-CODE + RUN if ! getent passwd #{username}; then \ + useradd -d #{homedir} -m -s /bin/bash -p '*' #{username}; \ + fi + RUN echo "#{username} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + RUN echo "Defaults !requiretty" >> /etc/sudoers + RUN mkdir -p #{homedir}/.ssh + RUN chown -R #{username} #{homedir}/.ssh + RUN chmod 0700 #{homedir}/.ssh + RUN touch #{homedir}/.ssh/authorized_keys + RUN chown #{username} #{homedir}/.ssh/authorized_keys + RUN chmod 0600 #{homedir}/.ssh/authorized_keys + RUN mkdir -p /run/sshd + CODE + + custom = '' + Array(@config[:provision_command]).each do |cmd| + custom << "RUN #{cmd}\n" + end + + ssh_key = "RUN echo #{Shellwords.escape(public_key)} >> #{homedir}/.ssh/authorized_keys" + + # Empty string to ensure the file ends with a newline. + output = [from, dockerfile_proxy_config, platform, base, custom, ssh_key, ''].join("\n") + debug('--- Start Dockerfile ---') + debug(output.strip) + debug('--- End Dockerfile ---') + output + end + end + end + end +end diff --git a/lib/kitchen/docker/container/windows.rb b/lib/kitchen/docker/container/windows.rb new file mode 100644 index 00000000..b3c93acd --- /dev/null +++ b/lib/kitchen/docker/container/windows.rb @@ -0,0 +1,84 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'securerandom' + +require 'kitchen/docker/container' + +module Kitchen + module Docker + class Container + class Windows < Kitchen::Docker::Container + def initialize(config) + super + end + + def create(state) + super + + debug('Creating Windows container') + state[:username] = @config[:username] + state[:image_id] = build_image(state, dockerfile) unless state[:image_id] + state[:container_id] = run_container(state) unless state[:container_id] + end + + def execute(command) + # Create temp script file and upload files to container + debug('Executing command on Windows container') + filename = "docker-#{::SecureRandom.uuid}.ps1" + temp_file = ".\\.kitchen\\temp\\#{filename}" + create_temp_file(temp_file, command) + + remote_path = @config[:temp_dir].tr('/', '\\') + debug("Creating directory #{remote_path} on container") + create_dir_on_container(@config, remote_path) + + debug("Uploading temp file #{temp_file} to #{remote_path} on container") + upload(temp_file, remote_path) + + debug('Deleting temp file from local filesystem') + ::File.delete(temp_file) + + # Replace any environment variables used in the path and execute script file + debug("Executing temp script #{remote_path}\\#{filename} on container") + remote_path = replace_env_variables(@config, remote_path) + cmd = build_powershell_command("-File #{remote_path}\\#{filename}") + + container_exec(@config, cmd) + rescue => e + raise "Failed to execute command on Windows container. #{e}" + end + + protected + + def dockerfile + raise ActionFailed, "Unknown platform '#{@config[:platform]}'" unless @config[:platform] == 'windows' + return dockerfile_template if @config[:dockerfile] + + from = "FROM #{@config[:image]}" + + custom = '' + Array(@config[:provision_command]).each do |cmd| + custom << "RUN #{cmd}\n" + end + + output = [from, dockerfile_proxy_config, custom, ''].join("\n") + debug('--- Start Dockerfile ---') + debug(output.strip) + debug('--- End Dockerfile ---') + output + end + end + end + end +end diff --git a/lib/kitchen/driver/docker_version.rb b/lib/kitchen/docker/docker_version.rb similarity index 93% rename from lib/kitchen/driver/docker_version.rb rename to lib/kitchen/docker/docker_version.rb index f1dee38a..b68d5830 100644 --- a/lib/kitchen/driver/docker_version.rb +++ b/lib/kitchen/docker/docker_version.rb @@ -1,4 +1,3 @@ -# -*- encoding: utf-8 -*- # # Copyright (C) 2014, Sean Porter # @@ -15,9 +14,7 @@ # limitations under the License. module Kitchen - - module Driver - + module Docker # Version string for Docker Kitchen driver DOCKER_VERSION = "2.9.0" end diff --git a/lib/kitchen/driver/docker/erb.rb b/lib/kitchen/docker/erb_context.rb similarity index 92% rename from lib/kitchen/driver/docker/erb.rb rename to lib/kitchen/docker/erb_context.rb index 24a0340d..7780e47c 100644 --- a/lib/kitchen/driver/docker/erb.rb +++ b/lib/kitchen/docker/erb_context.rb @@ -1,4 +1,3 @@ -# -*- encoding: utf-8 -*- # # Copyright (C) 2014, Sean Porter # @@ -17,10 +16,8 @@ require 'erb' module Kitchen - - module Driver - - class DockerERBContext + module Docker + class ERBContext def initialize(config={}) config.each do |key, value| instance_variable_set('@' + key.to_s, value) diff --git a/lib/kitchen/docker/helpers/cli_helper.rb b/lib/kitchen/docker/helpers/cli_helper.rb new file mode 100644 index 00000000..a4356149 --- /dev/null +++ b/lib/kitchen/docker/helpers/cli_helper.rb @@ -0,0 +1,147 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'kitchen' +require 'kitchen/configurable' +require 'kitchen/logging' +require 'kitchen/shell_out' + +module Kitchen + module Docker + module Helpers + module CliHelper + include Configurable + include Logging + include ShellOut + + def docker_command(cmd, options={}) + docker = config[:binary].dup + docker << " -H #{config[:socket]}" if config[:socket] + docker << ' --tls' if config[:tls] + docker << ' --tlsverify' if config[:tls_verify] + docker << " --tlscacert=#{config[:tls_cacert]}" if config[:tls_cacert] + docker << " --tlscert=#{config[:tls_cert]}" if config[:tls_cert] + docker << " --tlskey=#{config[:tls_key]}" if config[:tls_key] + logger.debug("docker_command: #{docker} #{cmd} shell_opts: #{docker_shell_opts(options)}") + run_command("#{docker} #{cmd}", docker_shell_opts(options)) + end + + def build_run_command(image_id, transport_port = nil) + cmd = 'run -d' + cmd << ' -i' if config[:interactive] + cmd << ' -t' if config[:tty] + cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables] + cmd << " -p #{transport_port}" unless transport_port.nil? + Array(config[:forward]).each { |port| cmd << " -p #{port}" } + Array(config[:dns]).each { |dns| cmd << " --dns #{dns}" } + Array(config[:add_host]).each { |host, ip| cmd << " --add-host=#{host}:#{ip}" } + Array(config[:volume]).each { |volume| cmd << " -v #{volume}" } + Array(config[:volumes_from]).each { |container| cmd << " --volumes-from #{container}" } + Array(config[:links]).each { |link| cmd << " --link #{link}" } + Array(config[:devices]).each { |device| cmd << " --device #{device}" } + cmd << " --name #{config[:instance_name]}" if config[:instance_name] + cmd << ' -P' if config[:publish_all] + cmd << " -h #{config[:hostname]}" if config[:hostname] + cmd << " -m #{config[:memory]}" if config[:memory] + cmd << " -c #{config[:cpu]}" if config[:cpu] + cmd << " -e http_proxy=#{config[:http_proxy]}" if config[:http_proxy] + cmd << " -e https_proxy=#{config[:https_proxy]}" if config[:https_proxy] + cmd << ' --privileged' if config[:privileged] + Array(config[:cap_add]).each { |cap| cmd << " --cap-add=#{cap}"} if config[:cap_add] + Array(config[:cap_drop]).each { |cap| cmd << " --cap-drop=#{cap}"} if config[:cap_drop] + Array(config[:security_opt]).each { |opt| cmd << " --security-opt=#{opt}"} if config[:security_opt] + extra_run_options = config_to_options(config[:run_options]) + cmd << " #{extra_run_options}" unless extra_run_options.empty? + cmd << " #{image_id} #{config[:run_command]}" + logger.debug("build_run_command: #{cmd}") + cmd + end + + def build_exec_command(state, command) + cmd = 'exec' + cmd << ' -d' if config[:detach] + cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables] + cmd << ' --privileged' if config[:privileged] + cmd << ' -t' if config[:tty] + cmd << ' -i' if config[:interactive] + cmd << " -u #{config[:username]}" if config[:username] + cmd << " -w #{config[:working_dir]}" if config[:working_dir] + cmd << " #{state[:container_id]}" + cmd << " #{command}" + logger.debug("build_exec_command: #{cmd}") + cmd + end + + def build_copy_command(local_file, remote_file, opts = {}) + cmd = 'cp' + cmd << ' -a' if opts[:archive] + cmd << " #{local_file} #{remote_file}" + cmd + end + + def build_powershell_command(args) + cmd = 'powershell -ExecutionPolicy Bypass -NoLogo ' + cmd << args + logger.debug("build_powershell_command: #{cmd}") + cmd + end + + def build_env_variable_args(vars) + raise ActionFailed, 'Environment variables are not of a Hash type' unless vars.is_a?(Hash) + + args = '' + vars.each do |k, v| + args << " -e #{k.to_s.strip}=\"#{v.to_s.strip}\"" + end + + args + end + + def dev_null + case RbConfig::CONFIG['host_os'] + when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ + 'NUL' + else + '/dev/null' + end + end + + def docker_shell_opts(options = {}) + options[:live_stream] = nil if options[:suppress_output] + options.delete(:suppress_output) + + options + end + + # Convert the config input for `:build_options` or `:run_options` in to a + # command line string for use with Docker. + # + # @since 2.5.0 + # @param config [nil, String, Array, Hash] Config data to convert. + # @return [String] + def config_to_options(config) + case config + when nil + '' + when String + config + when Array + config.map { |c| config_to_options(c) }.join(' ') + when Hash + config.map { |k, v| Array(v).map { |c| "--#{k}=#{Shellwords.escape(c)}" }.join(' ') }.join(' ') + end + end + end + end + end +end diff --git a/lib/kitchen/docker/helpers/container_helper.rb b/lib/kitchen/docker/helpers/container_helper.rb new file mode 100644 index 00000000..132f43f7 --- /dev/null +++ b/lib/kitchen/docker/helpers/container_helper.rb @@ -0,0 +1,172 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'erb' +require 'json' +require 'shellwords' +require 'tempfile' +require 'uri' + +require 'kitchen' +require 'kitchen/configurable' +require 'kitchen/docker/erb_context' +require 'kitchen/docker/helpers/cli_helper' + +module Kitchen + module Docker + module Helpers + module ContainerHelper + include Configurable + include Kitchen::Docker::Helpers::CliHelper + + def parse_container_id(output) + container_id = output.chomp + + unless [12, 64].include?(container_id.size) + raise ActionFailed, 'Could not parse Docker run output for container ID' + end + + container_id + end + + def dockerfile_template + template = IO.read(File.expand_path(config[:dockerfile])) + context = Kitchen::Docker::ERBContext.new(config.to_hash) + ERB.new(template).result(context.get_binding) + end + + def remote_socket? + config[:socket] ? socket_uri.scheme == 'tcp' : false + end + + def socket_uri + URI.parse(config[:socket]) + end + + def dockerfile_path(file) + config[:build_context] ? Pathname.new(file.path).relative_path_from(Pathname.pwd).to_s : file.path + end + + def container_exists?(state) + state[:container_id] && !!docker_command("top #{state[:container_id]}") rescue false + end + + def container_exec(state, command) + cmd = build_exec_command(state, command) + docker_command(cmd) + rescue => e + raise "Failed to execute command on Docker container. #{e}" + end + + def create_dir_on_container(state, path) + path = replace_env_variables(state, path) + cmd = "mkdir -p #{path}" + + if state[:platform] == 'windows' + psh = "-Command if(-not (Test-Path \'#{path}\')) { New-Item -Path \'#{path}\' -Force }" + cmd = build_powershell_command(psh) + end + + cmd = build_exec_command(state, cmd) + docker_command(cmd) + rescue => e + raise "Failed to create directory #{path} on container. #{e}" + end + + def copy_file_to_container(state, local_file, remote_file) + debug("Copying local file #{local_file} to #{remote_file} on container") + + remote_file = replace_env_variables(state, remote_file) + + remote_file = "#{state[:container_id]}:#{remote_file}" + cmd = build_copy_command(local_file, remote_file) + docker_command(cmd) + rescue => e + raise "Failed to copy file #{local_file} to container. #{e}" + end + + def container_env_variables(state) + # Retrieves all environment variables from inside container + vars = {} + + if state[:platform] == 'windows' + cmd = build_powershell_command('-Command [System.Environment]::GetEnvironmentVariables() ^| ConvertTo-Json') + cmd = build_exec_command(state, cmd) + stdout = docker_command(cmd, suppress_output: !logger.debug?).strip + vars = ::JSON.parse(stdout) + else + cmd = build_exec_command(state, 'printenv') + stdout = docker_command(cmd, suppress_output: !logger.debug?).strip + stdout.split("\n").each { |line| vars[line.split('=')[0]] = line.split('=')[1] } + end + + vars + end + + def replace_env_variables(state, str) + if str.include?('$env:') + key = str[/\$env:(.*?)(\\|$)/, 1] + value = container_env_variables(state)[key].to_s.strip + str = str.gsub("$env:#{key}", value) + elsif str.include?('$') + key = str[/\$(.*?)(\/|$)/, 1] + value = container_env_variables(state)[key].to_s.strip + str = str.gsub("$#{key}", value) + end + + str + end + + def run_container(state, transport_port = nil) + cmd = build_run_command(state[:image_id], transport_port) + output = docker_command(cmd) + parse_container_id(output) + end + + def container_ip_address(state) + cmd = "inspect --format '{{ .NetworkSettings.IPAddress }}'" + cmd << " #{state[:container_id]}" + docker_command(cmd).strip + rescue + raise ActionFailed, 'Error getting internal IP of Docker container' + end + + def remove_container(state) + container_id = state[:container_id] + docker_command("stop -t 0 #{container_id}") + docker_command("rm #{container_id}") + end + + def dockerfile_proxy_config + env_variables = '' + if config[:http_proxy] + env_variables << "ENV http_proxy #{config[:http_proxy]}\n" + env_variables << "ENV HTTP_PROXY #{config[:http_proxy]}\n" + end + + if config[:https_proxy] + env_variables << "ENV https_proxy #{config[:https_proxy]}\n" + env_variables << "ENV HTTPS_PROXY #{config[:https_proxy]}\n" + end + + if config[:no_proxy] + env_variables << "ENV no_proxy #{config[:no_proxy]}\n" + env_variables << "ENV NO_PROXY #{config[:no_proxy]}\n" + end + + env_variables + end + end + end + end +end diff --git a/lib/kitchen/docker/helpers/file_helper.rb b/lib/kitchen/docker/helpers/file_helper.rb new file mode 100644 index 00000000..6dc8291d --- /dev/null +++ b/lib/kitchen/docker/helpers/file_helper.rb @@ -0,0 +1,40 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'fileutils' + +module Kitchen + module Docker + module Helpers + module FileHelper + def create_temp_file(file, contents) + debug("[Docker] Creating temp file #{file}") + debug('[Docker] --- Start Temp File Contents ---') + debug(contents) + debug('[Docker] --- End Temp File Contents ---') + + begin + path = ::File.dirname(file) + ::FileUtils.mkdir_p(path) unless ::Dir.exist?(path) + file = ::File.open(file, 'w') + file.write(contents) + rescue IOError => e + raise "Failed to write temp file. Error Details: #{e}" + ensure + file.close unless file.nil? + end + end + end + end + end +end diff --git a/lib/kitchen/docker/helpers/image_helper.rb b/lib/kitchen/docker/helpers/image_helper.rb new file mode 100644 index 00000000..25efaf86 --- /dev/null +++ b/lib/kitchen/docker/helpers/image_helper.rb @@ -0,0 +1,68 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'kitchen' +require 'kitchen/configurable' +require 'kitchen/docker/helpers/cli_helper' +require 'kitchen/docker/helpers/container_helper' + +module Kitchen + module Docker + module Helpers + module ImageHelper + include Configurable + include Kitchen::Docker::Helpers::CliHelper + include Kitchen::Docker::Helpers::ContainerHelper + + def parse_image_id(output) + output.each_line do |line| + if line =~ /image id|build successful|successfully built/i + return line.split(/\s+/).last + end + end + raise ActionFailed, 'Could not parse Docker build output for image ID' + end + + def remove_image(state) + image_id = state[:image_id] + docker_command("rmi #{image_id}") + end + + def build_image(state, dockerfile) + cmd = 'build' + cmd << ' --no-cache' unless config[:use_cache] + extra_build_options = config_to_options(config[:build_options]) + cmd << " #{extra_build_options}" unless extra_build_options.empty? + dockerfile_contents = dockerfile + build_context = config[:build_context] ? '.' : '-' + file = Tempfile.new('Dockerfile-kitchen', Dir.pwd) + output = begin + file.write(dockerfile) + file.close + docker_command("#{cmd} -f #{Shellwords.escape(dockerfile_path(file))} #{build_context}", + input: dockerfile_contents) + ensure + file.close unless file.closed? + file.unlink + end + + parse_image_id(output) + end + + def image_exists?(state) + state[:image_id] && !!docker_command("inspect --type=image #{state[:image_id]}") rescue false + end + end + end + end +end diff --git a/lib/kitchen/docker/helpers/inspec_helper.rb b/lib/kitchen/docker/helpers/inspec_helper.rb new file mode 100644 index 00000000..5f0d3256 --- /dev/null +++ b/lib/kitchen/docker/helpers/inspec_helper.rb @@ -0,0 +1,40 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This helper should be removed when the kitchen-inspec gem has been updated to include these runner options +begin + require 'kitchen/verifier/inspec' + + # Add runner options for Docker transport for kitchen-inspec gem + module Kitchen + module Docker + module Helpers + module InspecHelper + Kitchen::Verifier::Inspec.class_eval do + def runner_options_for_docker(config_data) + opts = { + 'backend' => 'docker', + 'logger' => logger, + 'host' => config_data[:container_id], + } + logger.debug "Connect to Container: #{opts['host']}" + opts + end + end + end + end + end + end +rescue LoadError => e + logger.debug("[Docker] kitchen-inspec gem not found for InSpec verifier. #{e}") +end diff --git a/lib/kitchen/driver/docker.rb b/lib/kitchen/driver/docker.rb index 636f377c..91369a48 100644 --- a/lib/kitchen/driver/docker.rb +++ b/lib/kitchen/driver/docker.rb @@ -1,457 +1,164 @@ -# -*- encoding: utf-8 -*- -# -# Copyright (C) 2014, Sean Porter -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require 'kitchen' -require 'json' -require 'securerandom' -require 'uri' -require 'net/ssh' -require 'tempfile' -require 'shellwords' -require 'base64' - -require 'kitchen/driver/base' - -require_relative './docker/erb' - -module Kitchen - module Driver - # Docker driver for Kitchen. - # - # @author Sean Porter - class Docker < Kitchen::Driver::Base - include ShellOut - - default_config :binary, 'docker' - default_config :socket, ENV['DOCKER_HOST'] || 'unix:///var/run/docker.sock' - default_config :privileged, false - default_config :cap_add, nil - default_config :cap_drop, nil - default_config :security_opt, nil - default_config :use_cache, true - default_config :remove_images, false - default_config :run_command, '/usr/sbin/sshd -D -o UseDNS=no -o UsePAM=no -o PasswordAuthentication=yes ' + - '-o UsePrivilegeSeparation=no -o PidFile=/tmp/sshd.pid' - default_config :username, 'kitchen' - default_config :tls, false - default_config :tls_verify, false - default_config :tls_cacert, nil - default_config :tls_cert, nil - default_config :tls_key, nil - default_config :publish_all, false - default_config :wait_for_sshd, true - default_config :private_key, File.join(Dir.pwd, '.kitchen', 'docker_id_rsa') - default_config :public_key, File.join(Dir.pwd, '.kitchen', 'docker_id_rsa.pub') - default_config :build_options, nil - default_config :run_options, nil - default_config :use_internal_docker_network, false - - default_config :use_sudo, false - - default_config :image do |driver| - driver.default_image - end - - default_config :platform do |driver| - driver.default_platform - end - - default_config :disable_upstart, true - - default_config :build_context do |driver| - !driver.remote_socket? - end - - default_config :instance_name do |driver| - # Borrowed from kitchen-rackspace - [ - driver.instance.name.gsub(/\W/, ''), - (Etc.getlogin || 'nologin').gsub(/\W/, ''), - Socket.gethostname.gsub(/\W/, '')[0..20], - Array.new(8) { rand(36).to_s(36) }.join - ].join('-') - end - - MUTEX_FOR_SSH_KEYS = Mutex.new - - def verify_dependencies - run_command("#{config[:binary]} >> #{dev_null} 2>&1", quiet: true, use_sudo: config[:use_sudo]) - rescue - raise UserError, - 'You must first install the Docker CLI tool https://www.docker.com/get-started' - end - - def dev_null - case RbConfig::CONFIG["host_os"] - when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ - "NUL" - else - "/dev/null" - end - end - - def default_image - platform, release = instance.platform.name.split('-') - if platform == 'centos' && release - release = 'centos' + release.split('.').first - end - release ? [platform, release].join(':') : platform - end - - def default_platform - instance.platform.name.split('-').first - end - - def create(state) - generate_keys - state[:username] = config[:username] - state[:ssh_key] = config[:private_key] - state[:image_id] = build_image(state) unless state[:image_id] - state[:container_id] = run_container(state) unless state[:container_id] - state[:hostname] = 'localhost' - if remote_socket? - state[:hostname] = socket_uri.host - elsif config[:use_internal_docker_network] - state[:hostname] = container_ip(state) - end - state[:port] = container_ssh_port(state) - if config[:wait_for_sshd] - instance.transport.connection(state) do |conn| - conn.wait_until_ready - end - end - end - - def destroy(state) - rm_container(state) if container_exists?(state) - if config[:remove_images] && state[:image_id] - rm_image(state) if image_exists?(state) - end - end - - def remote_socket? - config[:socket] ? socket_uri.scheme == 'tcp' : false - end - - protected - - def socket_uri - URI.parse(config[:socket]) - end - - def docker_command(cmd, options={}) - docker = config[:binary].dup - docker << " -H #{config[:socket]}" if config[:socket] - docker << " --tls" if config[:tls] - docker << " --tlsverify" if config[:tls_verify] - docker << " --tlscacert=#{config[:tls_cacert]}" if config[:tls_cacert] - docker << " --tlscert=#{config[:tls_cert]}" if config[:tls_cert] - docker << " --tlskey=#{config[:tls_key]}" if config[:tls_key] - run_command("#{docker} #{cmd}", options.merge({ - quiet: !logger.debug?, - use_sudo: config[:use_sudo], - log_subject: Thor::Util.snake_case(self.class.to_s), - })) - end - - def generate_keys - MUTEX_FOR_SSH_KEYS.synchronize do - if !File.exist?(config[:public_key]) || !File.exist?(config[:private_key]) - private_key = OpenSSL::PKey::RSA.new(2048) - blobbed_key = Base64.encode64(private_key.to_blob).gsub("\n", '') - public_key = "ssh-rsa #{blobbed_key} kitchen_docker_key" - File.open(config[:private_key], 'w') do |file| - file.write(private_key) - file.chmod(0600) - end - File.open(config[:public_key], 'w') do |file| - file.write(public_key) - file.chmod(0600) - end - end - end - end - - def build_dockerfile - from = "FROM #{config[:image]}" - - env_variables = '' - if config[:http_proxy] - env_variables << "ENV http_proxy #{config[:http_proxy]}\n" - env_variables << "ENV HTTP_PROXY #{config[:http_proxy]}\n" - end - - if config[:https_proxy] - env_variables << "ENV https_proxy #{config[:https_proxy]}\n" - env_variables << "ENV HTTPS_PROXY #{config[:https_proxy]}\n" - end - - if config[:no_proxy] - env_variables << "ENV no_proxy #{config[:no_proxy]}\n" - env_variables << "ENV NO_PROXY #{config[:no_proxy]}\n" - end - - platform = case config[:platform] - when 'debian', 'ubuntu' - disable_upstart = <<-eos - RUN [ ! -f "/sbin/initctl" ] || dpkg-divert --local --rename --add /sbin/initctl && ln -sf /bin/true /sbin/initctl - eos - packages = <<-eos - ENV DEBIAN_FRONTEND noninteractive - ENV container docker - RUN apt-get update - RUN apt-get install -y sudo openssh-server curl lsb-release - eos - config[:disable_upstart] ? disable_upstart + packages : packages - when 'rhel', 'centos', 'oraclelinux', 'amazonlinux' - <<-eos - ENV container docker - RUN yum clean all - RUN yum install -y sudo openssh-server openssh-clients which curl - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' - eos - when 'fedora' - <<-eos - ENV container docker - RUN dnf clean all - RUN dnf install -y sudo openssh-server openssh-clients which curl - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' - eos - when 'opensuse/tumbleweed', 'opensuse/leap', 'opensuse', 'sles' - <<-eos - ENV container docker - RUN zypper install -y sudo openssh which curl - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N '' - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N '' - eos - when 'arch' - # See https://bugs.archlinux.org/task/47052 for why we - # blank out limits.conf. - <<-eos - RUN pacman --noconfirm -Sy archlinux-keyring - RUN pacman-db-upgrade - RUN pacman --noconfirm -Syu openssl openssh sudo curl - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key - RUN echo >/etc/security/limits.conf - eos - when 'gentoo' - <<-eos - RUN emerge --sync - RUN emerge net-misc/openssh app-admin/sudo - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key - eos - when 'gentoo-paludis' - <<-eos - RUN cave sync - RUN cave resolve -zx net-misc/openssh app-admin/sudo - RUN [ -f "/etc/ssh/ssh_host_rsa_key" ] || ssh-keygen -A -t rsa -f /etc/ssh/ssh_host_rsa_key - RUN [ -f "/etc/ssh/ssh_host_dsa_key" ] || ssh-keygen -A -t dsa -f /etc/ssh/ssh_host_dsa_key - eos - else - raise ActionFailed, - "Unknown platform '#{config[:platform]}'" - end - - username = config[:username] - public_key = IO.read(config[:public_key]).strip - homedir = username == 'root' ? '/root' : "/home/#{username}" - - base = <<-eos - RUN if ! getent passwd #{username}; then \ - useradd -d #{homedir} -m -s /bin/bash -p '*' #{username}; \ - fi - RUN echo "#{username} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers - RUN echo "Defaults !requiretty" >> /etc/sudoers - RUN mkdir -p #{homedir}/.ssh - RUN chown -R #{username} #{homedir}/.ssh - RUN chmod 0700 #{homedir}/.ssh - RUN touch #{homedir}/.ssh/authorized_keys - RUN chown #{username} #{homedir}/.ssh/authorized_keys - RUN chmod 0600 #{homedir}/.ssh/authorized_keys - RUN mkdir -p /run/sshd - eos - custom = '' - Array(config[:provision_command]).each do |cmd| - custom << "RUN #{cmd}\n" - end - ssh_key = "RUN echo #{Shellwords.escape(public_key)} >> #{homedir}/.ssh/authorized_keys" - # Empty string to ensure the file ends with a newline. - [from, env_variables, platform, base, custom, ssh_key, ''].join("\n") - end - - def dockerfile - if config[:dockerfile] - template = IO.read(File.expand_path(config[:dockerfile])) - context = DockerERBContext.new(config.to_hash) - ERB.new(template).result(context.get_binding) - else - build_dockerfile - end - end - - def parse_image_id(output) - output.each_line do |line| - if line =~ /image id|build successful|successfully built/i - return line.split(/\s+/).last - end - end - raise ActionFailed, - 'Could not parse Docker build output for image ID' - end - - def build_image(state) - cmd = "build" - cmd << " --no-cache" unless config[:use_cache] - extra_build_options = config_to_options(config[:build_options]) - cmd << " #{extra_build_options}" unless extra_build_options.empty? - dockerfile_contents = dockerfile - build_context = config[:build_context] ? '.' : '-' - file = Tempfile.new('Dockerfile-kitchen', Dir.pwd) - output = begin - file.write(dockerfile) - file.close - docker_command("#{cmd} -f #{Shellwords.escape(dockerfile_path(file))} #{build_context}", :input => dockerfile_contents) - ensure - file.close unless file.closed? - file.unlink - end - parse_image_id(output) - end - - def parse_container_id(output) - container_id = output.chomp - unless [12, 64].include?(container_id.size) - raise ActionFailed, - 'Could not parse Docker run output for container ID' - end - container_id - end - - def build_run_command(image_id) - cmd = "run -d -p 22" - Array(config[:forward]).each {|port| cmd << " -p #{port}"} - Array(config[:dns]).each {|dns| cmd << " --dns #{dns}"} - Array(config[:add_host]).each {|host, ip| cmd << " --add-host=#{host}:#{ip}"} - Array(config[:volume]).each {|volume| cmd << " -v #{volume}"} - Array(config[:volumes_from]).each {|container| cmd << " --volumes-from #{container}"} - Array(config[:links]).each {|link| cmd << " --link #{link}"} - Array(config[:devices]).each {|device| cmd << " --device #{device}"} - cmd << " --name #{config[:instance_name]}" if config[:instance_name] - cmd << " -P" if config[:publish_all] - cmd << " -h #{config[:hostname]}" if config[:hostname] - cmd << " -m #{config[:memory]}" if config[:memory] - cmd << " -c #{config[:cpu]}" if config[:cpu] - cmd << " -e http_proxy=#{config[:http_proxy]}" if config[:http_proxy] - cmd << " -e https_proxy=#{config[:https_proxy]}" if config[:https_proxy] - cmd << " --privileged" if config[:privileged] - Array(config[:cap_add]).each {|cap| cmd << " --cap-add=#{cap}"} if config[:cap_add] - Array(config[:cap_drop]).each {|cap| cmd << " --cap-drop=#{cap}"} if config[:cap_drop] - Array(config[:security_opt]).each {|opt| cmd << " --security-opt=#{opt}"} if config[:security_opt] - extra_run_options = config_to_options(config[:run_options]) - cmd << " #{extra_run_options}" unless extra_run_options.empty? - cmd << " #{image_id} #{config[:run_command]}" - cmd - end - - def run_container(state) - cmd = build_run_command(state[:image_id]) - output = docker_command(cmd) - parse_container_id(output) - end - - def container_exists?(state) - state[:container_id] && !!docker_command("top #{state[:container_id]}") rescue false - end - - def image_exists?(state) - state[:image_id] && !!docker_command("docker inspect --type=image #{state[:image_id]}") rescue false - end - - def parse_container_ssh_port(output) - begin - _host, port = output.split(':') - port.to_i - rescue - raise ActionFailed, - 'Could not parse Docker port output for container SSH port' - end - end - - def container_ssh_port(state) - begin - if config[:use_internal_docker_network] - return 22 - end - output = docker_command("port #{state[:container_id]} 22/tcp") - parse_container_ssh_port(output) - rescue - raise ActionFailed, - 'Docker reports container has no ssh port mapped' - end - end - - def container_ip(state) - begin - cmd = "inspect --format '{{ .NetworkSettings.IPAddress }}'" - cmd << " #{state[:container_id]}" - docker_command(cmd).strip - rescue - raise ActionFailed, - 'Error getting internal IP of Docker container' - end - end - - def rm_container(state) - container_id = state[:container_id] - docker_command("stop -t 0 #{container_id}") - docker_command("rm #{container_id}") - end - - def rm_image(state) - image_id = state[:image_id] - docker_command("rmi #{image_id}") - end - - # Convert the config input for `:build_options` or `:run_options` in to a - # command line string for use with Docker. - # - # @since 2.5.0 - # @param config [nil, String, Array, Hash] Config data to convert. - # @return [String] - def config_to_options(config) - case config - when nil - '' - when String - config - when Array - config.map {|c| config_to_options(c) }.join(' ') - when Hash - config.map {|k, v| Array(v).map {|c| "--#{k}=#{Shellwords.escape(c)}" }.join(' ') }.join(' ') - end - end - - def dockerfile_path(file) - config[:build_context] ? Pathname.new(file.path).relative_path_from(Pathname.pwd).to_s : file.path - end - - end - end -end +# +# Copyright (C) 2014, Sean Porter +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'kitchen' +require 'json' +require 'securerandom' +require 'net/ssh' + +require 'kitchen/driver/base' + +require 'kitchen/docker/container/linux' +require 'kitchen/docker/container/windows' +require 'kitchen/docker/helpers/cli_helper' +require 'kitchen/docker/helpers/container_helper' + +module Kitchen + module Driver + # Docker driver for Kitchen. + # + # @author Sean Porter + class Docker < Kitchen::Driver::Base + include Kitchen::Docker::Helpers::CliHelper + include Kitchen::Docker::Helpers::ContainerHelper + include ShellOut + + default_config :binary, 'docker' + default_config :build_options, nil + default_config :cap_add, nil + default_config :cap_drop, nil + default_config :disable_upstart, true + default_config :env_variables, nil + default_config :interactive, false + default_config :private_key, File.join(Dir.pwd, '.kitchen', 'docker_id_rsa') + default_config :privileged, false + default_config :public_key, File.join(Dir.pwd, '.kitchen', 'docker_id_rsa.pub') + default_config :publish_all, false + default_config :remove_images, false + default_config :run_options, nil + default_config :security_opt, nil + default_config :tls, false + default_config :tls_cacert, nil + default_config :tls_cert, nil + default_config :tls_key, nil + default_config :tls_verify, false + default_config :tty, false + default_config :use_cache, true + default_config :use_internal_docker_network, false + default_config :use_sudo, false + default_config :wait_for_transport, true + + default_config :build_context do |driver| + !driver.remote_socket? + end + + default_config :image do |driver| + driver.default_image + end + + default_config :instance_name do |driver| + # Borrowed from kitchen-rackspace + [ + driver.instance.name.gsub(/\W/, ''), + (Etc.getlogin || 'nologin').gsub(/\W/, ''), + Socket.gethostname.gsub(/\W/, '')[0..20], + Array.new(8) { rand(36).to_s(36) }.join + ].join('-') + end + + default_config :platform do |driver| + driver.default_platform + end + + default_config :run_command do |driver| + if driver.windows_os? + # Launch arbitrary process to keep the Windows container alive + # If running in interactive mode, launch powershell.exe instead + if driver[:interactive] + 'powershell.exe' + else + 'ping -t localhost' + end + else + '/usr/sbin/sshd -D -o UseDNS=no -o UsePAM=no -o PasswordAuthentication=yes '\ + '-o UsePrivilegeSeparation=no -o PidFile=/tmp/sshd.pid' + end + end + + default_config :socket do |driver| + socket = 'unix:///var/run/docker.sock' + socket = 'npipe:////./pipe/docker_engine' if driver.windows_os? + ENV['DOCKER_HOST'] || socket + end + + default_config :username do |driver| + # Return nil to prevent username from being added to Docker + # command line args for Windows if a username was not specified + if driver.windows_os? + nil + else + 'kitchen' + end + end + + def verify_dependencies + run_command("#{config[:binary]} >> #{dev_null} 2>&1", quiet: true, use_sudo: config[:use_sudo]) + rescue + raise UserError, 'You must first install the Docker CLI tool https://www.docker.com/get-started' + end + + def create(state) + container.create(state) + + wait_for_transport(state) + end + + def destroy(state) + container.destroy(state) + end + + def wait_for_transport(state) + if config[:wait_for_transport] + instance.transport.connection(state) do |conn| + conn.wait_until_ready + end + end + end + + def default_image + platform, release = instance.platform.name.split('-') + if platform == 'centos' && release + release = 'centos' + release.split('.').first + end + release ? [platform, release].join(':') : platform + end + + def default_platform + instance.platform.name.split('-').first + end + + protected + + def container + @container ||= if windows_os? + Kitchen::Docker::Container::Windows.new(config) + else + Kitchen::Docker::Container::Linux.new(config) + end + @container + end + end + end +end diff --git a/lib/kitchen/transport/docker.rb b/lib/kitchen/transport/docker.rb new file mode 100644 index 00000000..66387acf --- /dev/null +++ b/lib/kitchen/transport/docker.rb @@ -0,0 +1,111 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'kitchen' + +require 'kitchen/docker/container/linux' +require 'kitchen/docker/container/windows' + +require 'kitchen/docker/helpers/inspec_helper' + +require_relative '../../docker/version.rb' +require_relative '../../train/docker.rb' + +module Kitchen + module Transport + class Docker < Kitchen::Transport::Base + class DockerFailed < TransportFailed; end + + kitchen_transport_api_version 1 + plugin_version Kitchen::VERSION + + default_config :binary, 'docker' + default_config :env_variables, nil + default_config :interactive, false + default_config :privileged, false + default_config :tls, false + default_config :tls_cacert, nil + default_config :tls_cert, nil + default_config :tls_key, nil + default_config :tls_verify, false + default_config :tty, false + default_config :working_dir, nil + + default_config :socket do |transport| + socket = 'unix:///var/run/docker.sock' + socket = 'npipe:////./pipe/docker_engine' if transport.windows_os? + ENV['DOCKER_HOST'] || socket + end + + default_config :temp_dir do |transport| + if transport.windows_os? + '$env:TEMP' + else + '/tmp' + end + end + + default_config :username do |transport| + # Return an empty string to prevent username from being added to Docker + # command line args for Windows if a username was not specified + if transport.windows_os? + nil + else + 'kitchen' + end + end + + def connection(state, &block) + options = config.to_hash.merge(state) + options[:platform] = instance.platform.name + + # Set value for DOCKER_HOST environment variable for the docker-api gem + # This allows Windows systems to use the TCP socket for the InSpec verifier + # See the lib/docker.rb file here: https://github.com/swipely/docker-api/blob/master/lib/docker.rb + # default_socket_url is set to a Unix socket and env_url requires an environment variable to be set + ENV['DOCKER_HOST'] = options[:socket] if !options[:socket].nil? && ENV['DOCKER_HOST'].nil? + + Kitchen::Transport::Docker::Connection.new(options, &block) + end + + class Connection < Kitchen::Transport::Docker::Connection + # Include the InSpec patches to be able to execute tests on Windows containers + include Kitchen::Docker::Helpers::InspecHelper + + def execute(command) + return if command.nil? + + debug("[Docker] Executing command: #{command}") + info("[Docker] Executing command on container") + + container.execute(command) + rescue => e + raise DockerFailed, "Docker failed to execute command on container. Error Details: #{e}" + end + + def upload(locals, remote) + container.upload(locals, remote) + end + + def container + @container ||= if @options[:platform] == 'windows' + Kitchen::Docker::Container::Windows.new(@options) + else + Kitchen::Docker::Container::Linux.new(@options) + end + @container + end + end + end + end +end diff --git a/lib/train/docker.rb b/lib/train/docker.rb new file mode 100644 index 00000000..024caae9 --- /dev/null +++ b/lib/train/docker.rb @@ -0,0 +1,125 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Monkey patched Docker train transport to support running the InSpec verifier on Windows +begin + # Requires train gem with a minimum version of 2.1.0 + require 'train' + + module Train::Transports + # Patched train transport with Windows support for InSpec verifier + class Docker < Train.plugin(1) + name 'docker' + + include_options Train::Extras::CommandWrapper + option :host, required: true + + def connection(state = {}, &block) + opts = merge_options(options, state || {}) + validate_options(opts) + + if @connection && @connection_options == opts + reuse_connection(&block) + else + create_new_connection(opts, &block) + end + end + + private + + # Creates a new Docker connection instance and save it for potential future + # reuse. + # + # @param options [Hash] connection options + # @return [Docker::Connection] a Docker connection instance + # @api private + def create_new_connection(options, &block) + if @connection + logger.debug("[Docker] shutting previous connection #{@connection}") + @connection.close + end + + @connection_options = options + @connection = Connection.new(options, &block) + end + + # Return the last saved Docker connection instance. + # + # @return [Docker::Connection] a Docker connection instance + # @api private + def reuse_connection + logger.debug("[Docker] reusing existing connection #{@connection}") + yield @connection if block_given? + @connection + end + end + end + + class Train::Transports::Docker + class Connection < BaseConnection + def initialize(conf) + super(conf) + @id = options[:host] + @container = ::Docker::Container.get(@id) || + fail("Can't find Docker container #{@id}") + @cmd_wrapper = nil + @cmd_wrapper = CommandWrapper.load(self, @options) + self + end + + def uri + if @container.nil? + "docker://#{@id}" + else + "docker://#{@container.id}" + end + end + + private + + def file_via_connection(path) + if os.aix? + Train::File::Remote::Aix.new(self, path) + elsif os.solaris? + Train::File::Remote::Unix.new(self, path) + elsif os.windows? + Train::File::Remote::Windows.new(self, path) + else + Train::File::Remote::Linux.new(self, path) + end + end + + def platform_specific_cmd(cmd) + return cmd if @container.info.nil? + if @container.info['Platform'] == 'windows' + return ['cmd.exe', '/c', cmd] + else + return ['/bin/sh', '-c', cmd] + end + end + + def run_command_via_connection(cmd, &_data_handler) + cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil? + stdout, stderr, exit_status = @container.exec(platform_specific_cmd(cmd)) + CommandResult.new(stdout.join, stderr.join, exit_status) + rescue ::Docker::Error::DockerError => _ + raise + rescue => _ + # @TODO: differentiate any other error + raise + end + end + end +rescue LoadError => e + logger.debug("[Docker] train gem not found for InSpec verifier. #{e}") +end diff --git a/test/integration/default/serverspec/default_spec.rb b/test/integration/default/serverspec/default_spec.rb index 46b60daa..5f3e3ec5 100644 --- a/test/integration/default/serverspec/default_spec.rb +++ b/test/integration/default/serverspec/default_spec.rb @@ -15,7 +15,7 @@ # require 'serverspec' -set :backend, :exec +require 'spec_helper' # Just make sure the image launched and is reachable. describe command('true') do diff --git a/test/integration/default/serverspec/spec_helper.rb b/test/integration/default/serverspec/spec_helper.rb new file mode 100644 index 00000000..42086cc4 --- /dev/null +++ b/test/integration/default/serverspec/spec_helper.rb @@ -0,0 +1,21 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +case RbConfig::CONFIG['host_os'] +when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ + set :backend, :cmd + set :os, :family => 'windows' +else + set :backend, :exec +end diff --git a/test/integration/inspec/inspec_spec.rb b/test/integration/inspec/inspec_spec.rb index b7274dd4..0a128d70 100644 --- a/test/integration/inspec/inspec_spec.rb +++ b/test/integration/inspec/inspec_spec.rb @@ -15,6 +15,12 @@ # # Just make sure the image launched and is reachable. -describe command('true') do - its(:exit_status) { is_expected.to eq 0 } +if os[:family] == 'windows' + describe command('echo 1') do + its(:exit_status) { is_expected.to eq 0 } + end +else + describe command('true') do + its(:exit_status) { is_expected.to eq 0 } + end end