Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ machine to boot.
* [Driver configuration](#driver-configuration)
* [Transport configuration](#transport-configuration)
* [Logging into a container](#logging-into-a-container)
* [Other commands](#other-commands)
* [Examples](#examples)
* [Using with Chef](#using-with-chef)
* [Troubleshooting](#troubleshooting)
Expand Down Expand Up @@ -207,6 +208,7 @@ platforms:
| `build_tempdir` | working directory | Where the generated Dockerfile is written, relative to `build_context`. |
| `use_cache` | `true` | Use Docker's build cache. `false` adds `--no-cache`. |
| `remove_images` | `false` | Remove the built image on `kitchen destroy`. |
| `package_name` | the instance name, tagged `latest` | Image `kitchen package` commits the container to. |
| `docker_platform` | *(none)* | Target architecture, passed as `--platform` to both build and run — e.g. `linux/arm64`. |

### Provisioning the image
Expand Down Expand Up @@ -334,6 +336,68 @@ starts `powershell`. The transport's `username`, `working_dir`,
`env_variables`, and `privileged` settings are honoured, so the shell matches
the environment the provisioner ran in.

## Other commands

### `kitchen package`

Commits the container to a Docker image, so a converged instance can be kept
and reused:

```sh
kitchen package default-ubuntu-2404
```

```text
-----> Packaging remote instance
[Docker] Packaged default-ubuntu-2404 as default-ubuntu-2404:latest (sha256:1f51c590...)
```

The image is named after the instance. Set `package_name` for something else:

```yaml
driver:
name: docker
package_name: myapp/under-test:candidate
```

Run `docker save` against the result if you want a tarball.

### `kitchen doctor`

Checks that the daemon is reachable and that the configuration points at
things that exist:

```sh
kitchen doctor default-ubuntu-2404
```

```text
-----> The doctor is in
Docker daemon at unix:///var/run/docker.sock is reachable, running 29.7.2.
```

It reports every problem it finds rather than stopping at the first, and exits
non-zero when there is one: a daemon it cannot reach, a `tls_cacert`,
`tls_cert`, `tls_key`, or `dockerfile` naming a path that is not there, or a
state file naming a container the daemon no longer has.

### `kitchen list --live`

Asks Docker what state each container is actually in, rather than reporting
only the last action Test Kitchen took:

```sh
kitchen list --live
```

```text
Instance Driver Provisioner Verifier Transport Last Action Last Error Live Status
default-ubuntu-2404 Docker Shell Dummy Docker Created <None> running
```

`running`, `stopped`, `gone` (the state file names a container the daemon does
not have), or `not created`.

## Examples

### Testing a systemd service
Expand Down
122 changes: 122 additions & 0 deletions lib/kitchen/driver/docker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require "json" unless defined?(JSON)
require "securerandom" unless defined?(SecureRandom)
require "net/ssh" unless defined?(Net::SSH)
require "time" unless defined?(Time)

require "kitchen/driver/base"

Expand Down Expand Up @@ -79,6 +80,13 @@ class Docker < Kitchen::Driver::Base
].join("-").downcase
end

# The image `kitchen package` commits to. Derived from the instance name,
# which is already lowercase and dash-separated, so it is a valid Docker
# repository name as it stands.
default_config :package_name do |driver|
"#{driver.instance.name.downcase.gsub(/[^a-z0-9_.-]/, "-")}:latest"
end

default_config :platform, &:default_platform

default_config :run_command do |driver|
Expand Down Expand Up @@ -140,6 +148,82 @@ def destroy(state)
container.destroy(state)
end

# Commits the container to a Docker image.
#
# `kitchen package` asks a driver to turn a converged instance into
# something reusable. For Docker that is an image: `docker commit` on the
# running container, which is the artifact every other docker tool
# already takes. Run `docker save` against the result for a tarball.
#
# @param state [Hash] instance state naming the container
# @return [void]
# @raise [Kitchen::ActionFailed] if the instance has not been created
def package(state)
unless state[:container_id]
raise ActionFailed, "Cannot package #{instance.name}: it has not been created."
end

# Asked here rather than left to `docker commit`, which reports a
# container that is gone as a bare "Error response from daemon: No such
# container: <64 hex characters>" with nothing about the instance or
# what to do next.
unless container_exists?(state)
raise ActionFailed, "Cannot package #{instance.name}: the state file names container " \
"#{state[:container_id]}, which the daemon does not have. " \
"Run `kitchen destroy` to clear it."
end

name = config[:package_name]
info("[Docker] Committing container #{state[:container_id]} to #{name}")
output = docker_command("commit #{shell_escape(state[:container_id])} #{shell_escape(name)}",
suppress_output: !logger.debug?)
image_id = output.lines.map(&:strip).find { |line| line.match?(/\Asha256:[[:xdigit:]]{64}\z/) }
info("[Docker] Packaged #{instance.name} as #{name}#{" (#{image_id})" if image_id}")
end

# Checks the configuration and the daemon it points at.
#
# Run by `kitchen doctor`. A true return is how Test Kitchen decides to
# exit non-zero, so every check runs and the results are OR-ed together
# rather than returning at the first problem -- somebody running `doctor`
# wants the whole list, not the first item on it.
#
# @param state [Hash] instance state
# @return [Boolean] whether a problem was found
def doctor(state)
[
doctor_daemon,
doctor_files,
doctor_container(state),
].any?
end

# Reports whether the container backing this instance is up.
#
# Read by `kitchen list --live`, which showed "unknown" for every
# instance: {Kitchen::Driver::Base} cannot know, and this driver never
# said. Docker can answer directly, and these are the same two questions
# create and destroy already ask.
#
# @param state [Hash] instance state naming the container
# @return [Hash] normalized status data
def status(state)
common = { source: "driver", checked_at: Time.now.utc.iso8601, resource_id: state[:container_id] }

if !state[:container_id]
common.merge(live: false, state: "not created",
message: "No container is recorded in the state file")
elsif !container_exists?(state)
common.merge(live: false, state: "gone",
message: "The state file names a container the daemon does not have")
elsif container_running?(state)
common.merge(live: true, state: "running")
else
common.merge(live: false, state: "stopped",
message: "The container exists but is not running")
end
end

# Waits for the transport to accept a connection, unless disabled.
#
# @param state [Hash] instance state describing how to connect
Expand Down Expand Up @@ -171,6 +255,44 @@ def default_platform

protected

# @return [Boolean] whether the daemon could not be reached
def doctor_daemon
version = docker_command("version --format '{{.Server.Version}}'", suppress_output: true).strip
info("Docker daemon at #{config[:socket]} is reachable, running #{version}.")
false
rescue => e
error("Cannot reach the Docker daemon at #{config[:socket]}. #{e}")
true
end

# Checks paths the configuration names.
#
# A missing TLS file or Dockerfile is worth catching here because docker
# reports it far from the cause -- a missing client certificate surfaces
# as a connection error rather than as a missing file.
#
# @return [Boolean] whether any named path is missing
def doctor_files
%i{tls_cacert tls_cert tls_key dockerfile}.map do |key|
path = config[key]
next false if path.nil? || ::File.exist?(::File.expand_path(path))

error("#{key} is set to #{path}, which does not exist.")
true
end.any?
end

# @param state [Hash] instance state naming the container
# @return [Boolean] whether state names a container that is gone
def doctor_container(state)
return false unless state[:container_id]
return false if container_exists?(state)

error("The state file names container #{state[:container_id]}, which the daemon does " \
"not have. Run `kitchen destroy` to clear it.")
true
end

# The container implementation for this platform.
#
# @return [Kitchen::Docker::Container] a Windows or Linux container
Expand Down
153 changes: 153 additions & 0 deletions spec/docker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,159 @@
end # /context with a hash of strings with spaces
end # /describe #config_to_options

# `kitchen package`, `kitchen doctor`, and `kitchen list --live` each ask the
# driver a question. Driver::Base answers all three with a shrug, and this
# driver used to inherit that: package produced nothing, doctor said nothing,
# and every instance listed as "unknown". Docker can answer all three.
def driver(config = {}, instance_name: "default-ubuntu-2404")
described_class.new(config).tap do |d|
allow(d).to receive(:instance).and_return(instance_double("Kitchen::Instance", name: instance_name))
allow(d).to receive(:logger).and_return(double(debug?: false, debug: nil))
%i{info error debug banner}.each { |level| allow(d).to receive(level) }
end
end

describe "#status" do
let(:state) { { container_id: "abc123abc123" } }

def status_of(exists:, running:, state: { container_id: "abc123abc123" })
d = driver
allow(d).to receive(:container_exists?).and_return(exists)
allow(d).to receive(:container_running?).and_return(running)
d.status(state)
end

it "reports a running container as live" do
expect(status_of(exists: true, running: true))
.to include(live: true, state: "running", source: "driver")
end

it "distinguishes a stopped container from a missing one" do
expect(status_of(exists: true, running: false)).to include(live: false, state: "stopped")
expect(status_of(exists: false, running: false)).to include(live: false, state: "gone")
end

it "reports an instance with no container as not created" do
expect(status_of(exists: false, running: false, state: {}))
.to include(live: false, state: "not created")
end

it "names the container so `kitchen list --live` can show it" do
expect(status_of(exists: true, running: true)[:resource_id]).to eq "abc123abc123"
end

it "does not ask docker about an instance that has no container" do
d = driver
expect(d).not_to receive(:container_exists?)
d.status({})
end

it "stamps when it looked" do
expect(status_of(exists: true, running: true)[:checked_at])
.to match(/\A\d{4}-\d{2}-\d{2}T[\d:]+Z\z/)
end
end

describe "#package" do
let(:state) { { container_id: "abc123abc123" } }
let(:digest) { "sha256:#{"a" * 64}" }

it "commits the container to the configured image name" do
d = driver({ package_name: "myapp:v1" })
allow(d).to receive(:container_exists?).and_return(true)
expect(d).to receive(:docker_command)
.with("commit abc123abc123 myapp:v1", hash_including(:suppress_output))
.and_return("#{digest}\n")
d.package(state)
end

it "names the image after the instance by default" do
d = driver
expect(d.send(:config)[:package_name]).to eq "default-ubuntu-2404:latest"
end

it "escapes a package name that would otherwise split" do
d = driver({ package_name: "my app:v1" })
allow(d).to receive(:container_exists?).and_return(true)
expect(d).to receive(:docker_command)
.with(%q{commit abc123abc123 my\ app:v1}, hash_including(:suppress_output))
.and_return("#{digest}\n")
d.package(state)
end

it "refuses to package an instance that was never created" do
expect { driver.package({}) }
.to raise_error(Kitchen::ActionFailed, /has not been created/)
end

it "does not run docker for an instance that was never created" do
d = driver
expect(d).not_to receive(:docker_command)
expect { d.package({}) }.to raise_error(Kitchen::ActionFailed)
end

# `docker commit` on a container that is gone says only "Error response
# from daemon: No such container: <64 hex characters>", which names neither
# the instance nor what to do about it.
it "names the instance when the container is gone" do
d = driver
allow(d).to receive(:container_exists?).and_return(false)
expect { d.package(state) }
.to raise_error(Kitchen::ActionFailed, /default-ubuntu-2404.*kitchen destroy/m)
end
end

describe "#doctor" do
let(:state) { {} }

def doctor_with(config = {}, daemon: "29.7.2", state: {})
d = driver(config)
allow(d).to receive(:container_exists?).and_return(true)
allow(d).to receive(:docker_command) do
raise Kitchen::ShellOut::ShellCommandFailed, "cannot connect" if daemon.nil?

"#{daemon}\n"
end
d.doctor(state)
end

it "reports no problem when the daemon answers" do
expect(doctor_with).to be false
end

it "reports a problem when the daemon cannot be reached" do
expect(doctor_with(daemon: nil)).to be true
end

it "reports a TLS file that is not there" do
expect(doctor_with({ tls_cert: "/nope/cert.pem" })).to be true
end

it "reports a dockerfile that is not there" do
expect(doctor_with({ dockerfile: "/nope/Dockerfile" })).to be true
end

it "accepts paths that do exist" do
expect(doctor_with({ dockerfile: __FILE__ })).to be false
end

it "reports a state file naming a container the daemon does not have" do
d = driver
allow(d).to receive(:docker_command).and_return("29.7.2\n")
allow(d).to receive(:container_exists?).and_return(false)
expect(d.doctor(container_id: "abc123abc123")).to be true
end

it "keeps checking after the first problem, so the whole list is reported" do
# `kitchen doctor` exists to tell you everything that is wrong at once.
d = driver({ tls_cert: "/nope/cert.pem", dockerfile: "/nope/Dockerfile" })
allow(d).to receive(:docker_command).and_raise(Kitchen::ShellOut::ShellCommandFailed, "nope")
allow(d).to receive(:container_exists?).and_return(false)
expect(d).to receive(:error).at_least(4).times
d.doctor(container_id: "abc123abc123")
end
end

describe "socket default config logic" do
def resolve_socket
socket = "unix:///var/run/docker.sock"
Expand Down
Loading