diff --git a/README.md b/README.md index e546823d..a4a8dfb8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 running +``` + +`running`, `stopped`, `gone` (the state file names a container the daemon does +not have), or `not created`. + ## Examples ### Testing a systemd service diff --git a/lib/kitchen/driver/docker.rb b/lib/kitchen/driver/docker.rb index f6ee692a..786a1859 100644 --- a/lib/kitchen/driver/docker.rb +++ b/lib/kitchen/driver/docker.rb @@ -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" @@ -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| @@ -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 @@ -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 diff --git a/spec/docker_spec.rb b/spec/docker_spec.rb index 9e8f9db8..4d81e2ad 100644 --- a/spec/docker_spec.rb +++ b/spec/docker_spec.rb @@ -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"