kitchen-docker 3.3.0 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,8 +21,14 @@ require_relative "../docker/helpers/inspec_helper"
21
21
  # require_relative "../../docker/version"
22
22
 
23
23
  module Kitchen
24
+ # Test Kitchen's transport plugins.
24
25
  module Transport
26
+ # Test Kitchen transport that runs commands inside a Docker container.
27
+ #
28
+ # Commands go through `docker exec` rather than a network protocol, so no
29
+ # SSH or WinRM server is needed inside the image.
25
30
  class Docker < Kitchen::Transport::Base
31
+ # Raised when a docker command against the container fails.
26
32
  class DockerFailed < TransportFailed; end
27
33
 
28
34
  # kitchen_transport_api_version 1
@@ -64,6 +70,15 @@ module Kitchen
64
70
  end
65
71
  end
66
72
 
73
+ # Builds a connection to the container.
74
+ #
75
+ # +DOCKER_HOST+ is exported here because the docker-api gem, used by the
76
+ # InSpec verifier, reads the daemon address from the environment rather
77
+ # than from Test Kitchen's configuration.
78
+ #
79
+ # @param state [Hash] instance state naming the container
80
+ # @yieldparam connection [Connection] if a block is given
81
+ # @return [Connection]
67
82
  def connection(state, &block)
68
83
  options = config.to_hash.merge(state)
69
84
  options[:platform] = instance.platform.name
@@ -77,10 +92,19 @@ module Kitchen
77
92
  Kitchen::Transport::Docker::Connection.new(options, &block)
78
93
  end
79
94
 
80
- class Connection < Kitchen::Transport::Docker::Connection
95
+ # A connection to one container.
96
+ #
97
+ # The superclass is named in full rather than relying on Ruby resolving
98
+ # the bare `Connection` constant through this class's ancestors.
99
+ class Connection < Kitchen::Transport::Base::Connection
81
100
  # Include the InSpec patches to be able to execute tests on Windows containers
82
101
  include Kitchen::Docker::Helpers::InspecHelper
83
102
 
103
+ # Runs a command inside the container.
104
+ #
105
+ # @param command [String] the command to run; nil is a no-op
106
+ # @return [void]
107
+ # @raise [DockerFailed] if the command fails
84
108
  def execute(command)
85
109
  return if command.nil?
86
110
 
@@ -92,10 +116,18 @@ module Kitchen
92
116
  raise DockerFailed, "Docker failed to execute command on container. Error Details: #{e}"
93
117
  end
94
118
 
119
+ # Copies local files into the container.
120
+ #
121
+ # @param locals [String, Array<String>] one path or several
122
+ # @param remote [String] destination path inside the container
123
+ # @return [Array<String>] the files copied
95
124
  def upload(locals, remote)
96
125
  container.upload(locals, remote)
97
126
  end
98
127
 
128
+ # The container implementation for this platform.
129
+ #
130
+ # @return [Kitchen::Docker::Container] a Windows or Linux container
99
131
  def container
100
132
  @container ||= if windows_container?
101
133
  Kitchen::Docker::Container::Windows.new(@options)
@@ -105,7 +137,13 @@ module Kitchen
105
137
  @container
106
138
  end
107
139
 
108
- # (see Base::Connection#login_command)
140
+ # The command `kitchen login` execs to open a shell in the container.
141
+ #
142
+ # Documented here rather than inherited via `(see ...)`, because the
143
+ # superclass lives in the test-kitchen gem and YARD cannot resolve a
144
+ # reference into it from this project's docs.
145
+ #
146
+ # @return [Kitchen::LoginCommand] an interactive `docker exec` session
109
147
  def login_command
110
148
  argv = build_login_command
111
149
  LoginCommand.new(argv.first, argv.drop(1))
@@ -113,6 +151,7 @@ module Kitchen
113
151
 
114
152
  private
115
153
 
154
+ # @return [Boolean] whether the platform under test is Windows
116
155
  def windows_container?
117
156
  @options[:platform].to_s.include?("windows")
118
157
  end
@@ -150,6 +189,8 @@ module Kitchen
150
189
  docker + cmd
151
190
  end
152
191
 
192
+ # @return [Array<String>] the shell to drop the user into, PowerShell on
193
+ # Windows and an interactive login bash elsewhere
153
194
  def login_shell
154
195
  windows_container? ? ["powershell"] : ["/bin/bash", "--login", "-i"]
155
196
  end
@@ -0,0 +1,320 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+
17
+ describe Kitchen::Docker::Helpers::CliHelper do
18
+ let(:image_id) { "sha256:abc123" }
19
+
20
+ # Assertions are made against the argument vector a shell would produce, not
21
+ # against the command string. See spec/support/argv.rb for why.
22
+ def run_argv(config = {})
23
+ argv(helper(config.merge(run_command: "/usr/sbin/sshd -D")).build_run_command(image_id))
24
+ end
25
+
26
+ describe "#build_run_command" do
27
+ describe "the baseline command" do
28
+ it "runs the image detached, with the configured run command" do
29
+ expect(run_argv).to eq ["run", "-d", image_id, "/usr/sbin/sshd", "-D"]
30
+ end
31
+
32
+ it "adds nothing that was not configured" do
33
+ # A flag leaking in when its option is unset is a silent behaviour
34
+ # change for every user; pinning the empty case catches it.
35
+ expect(run_argv.length).to eq 5
36
+ end
37
+ end
38
+
39
+ # Each entry pairs a configuration with the arguments Docker must receive.
40
+ # A dropped or misspelled flag is the failure mode this table exists for:
41
+ # the option is accepted in kitchen.yml, nothing happens, and nothing warns.
42
+ {
43
+ "privileged" => [{ privileged: true }, %w{--privileged}],
44
+ "publish_all" => [{ publish_all: true }, %w{-P}],
45
+ "interactive" => [{ interactive: true }, %w{-i}],
46
+ "tty" => [{ tty: true }, %w{-t}],
47
+ "hostname" => [{ hostname: "web.local" }, %w{-h web.local}],
48
+ "memory" => [{ memory: "512m" }, %w{-m 512m}],
49
+ "cpu" => [{ cpu: "512" }, %w{-c 512}],
50
+ "gpus" => [{ gpus: "all" }, %w{--gpus all}],
51
+ "isolation" => [{ isolation: "hyperv" }, %w{--isolation hyperv}],
52
+ "instance_name" => [{ instance_name: "web" }, %w{--name web}],
53
+ "forward" => [{ forward: "80:8080" }, %w{-p 80:8080}],
54
+ "dns" => [{ dns: "8.8.8.8" }, %w{--dns 8.8.8.8}],
55
+ "volume" => [{ volume: "/ftp:/ftp" }, %w{-v /ftp:/ftp}],
56
+ "volumes_from" => [{ volumes_from: "repos" }, %w{--volumes-from repos}],
57
+ "links" => [{ links: "db:db" }, %w{--link db:db}],
58
+ "devices" => [{ devices: "/dev/vboxdrv" }, %w{--device /dev/vboxdrv}],
59
+ "tmpfs" => [{ tmpfs: "/tmp" }, %w{--tmpfs /tmp}],
60
+ "mount" => [{ mount: "type=tmpfs,destination=/run" }, %w{--mount type=tmpfs,destination=/run}],
61
+ "add_host" => [{ add_host: { "db" => "10.0.0.5" } }, %w{--add-host=db:10.0.0.5}],
62
+ "cap_add" => [{ cap_add: "SYS_PTRACE" }, %w{--cap-add=SYS_PTRACE}],
63
+ "cap_drop" => [{ cap_drop: "CHOWN" }, %w{--cap-drop=CHOWN}],
64
+ "security_opt" => [{ security_opt: "apparmor:my_profile" }, %w{--security-opt=apparmor:my_profile}],
65
+ "docker_platform" => [{ docker_platform: "linux/arm64" }, %w{--platform=linux/arm64}],
66
+ "http_proxy" => [{ http_proxy: "http://p:8080" }, %w{-e http_proxy=http://p:8080}],
67
+ "https_proxy" => [{ https_proxy: "http://p:8080" }, %w{-e https_proxy=http://p:8080}],
68
+ }.each do |option, (config, expected)|
69
+ it "passes #{option} through as #{expected.join(" ")}" do
70
+ expect(run_argv(config)).to include_consecutive(*expected)
71
+ end
72
+ end
73
+
74
+ describe "options documented as accepting one value or a list" do
75
+ # README tells users these take either form. Array() is what makes that
76
+ # true, and it is easy to drop when an option is edited.
77
+ %i{forward dns volume volumes_from links devices tmpfs mount cap_add cap_drop security_opt}.each do |option|
78
+ it "accepts a bare value for #{option}" do
79
+ expect { run_argv(option => "one") }.not_to raise_error
80
+ end
81
+
82
+ it "emits one argument group per entry for #{option}" do
83
+ many = run_argv(option => %w{one two})
84
+ expect(many.grep(/one/)).not_to be_empty
85
+ expect(many.grep(/two/)).not_to be_empty
86
+ end
87
+ end
88
+ end
89
+
90
+ describe "the transport port" do
91
+ it "publishes the port the transport asks for" do
92
+ cmd = helper({ run_command: "sshd" }).build_run_command(image_id, 2222)
93
+ expect(argv(cmd)).to include_consecutive("-p", "2222")
94
+ end
95
+
96
+ it "publishes nothing when the transport asks for no port" do
97
+ cmd = helper({ run_command: "sshd" }).build_run_command(image_id, nil)
98
+ expect(argv(cmd)).not_to include "-p"
99
+ end
100
+ end
101
+
102
+ describe "quoting" do
103
+ # Docker command lines are built as strings and handed to a shell, so a
104
+ # configured value containing a space has to survive shell splitting as a
105
+ # single argument. These are the cases where it does not.
106
+ it "keeps a volume path containing a space as one argument" do
107
+ expect(run_argv(volume: "/host dir:/data")).to include_consecutive("-v", "/host dir:/data")
108
+ end
109
+
110
+ it "keeps a hostname containing a space as one argument" do
111
+ expect(run_argv(hostname: "two words")).to include_consecutive("-h", "two words")
112
+ end
113
+
114
+ it "keeps a mount specification containing a space as one argument" do
115
+ expect(run_argv(mount: "type=bind,source=/my dir,destination=/d"))
116
+ .to include_consecutive("--mount", "type=bind,source=/my dir,destination=/d")
117
+ end
118
+ end
119
+ end
120
+
121
+ describe "#build_exec_command" do
122
+ let(:state) { { container_id: "abc123" } }
123
+
124
+ def exec_argv(config = {}, command: "whoami")
125
+ argv(helper(config).build_exec_command(state, command))
126
+ end
127
+
128
+ it "execs the command in the container" do
129
+ expect(exec_argv).to eq %w{exec abc123 whoami}
130
+ end
131
+
132
+ {
133
+ "detach" => [{ detach: true }, %w{-d}],
134
+ "privileged" => [{ privileged: true }, %w{--privileged}],
135
+ "tty" => [{ tty: true }, %w{-t}],
136
+ "interactive" => [{ interactive: true }, %w{-i}],
137
+ "username" => [{ username: "kitchen" }, %w{-u kitchen}],
138
+ "working_dir" => [{ working_dir: "/srv" }, %w{-w /srv}],
139
+ }.each do |option, (config, expected)|
140
+ it "passes #{option} through as #{expected.join(" ")}" do
141
+ expect(exec_argv(config)).to include_consecutive(*expected)
142
+ end
143
+ end
144
+
145
+ it "puts the container id before the command" do
146
+ # Reversing these makes Docker try to exec into the command name, with an
147
+ # error that does not point anywhere near the cause.
148
+ result = exec_argv({ username: "kitchen" }, command: "whoami")
149
+ expect(result.index("abc123")).to be < result.index("whoami")
150
+ end
151
+ end
152
+
153
+ describe "#build_env_variable_args" do
154
+ it "emits one -e flag per variable" do
155
+ expect(argv(helper.build_env_variable_args("A" => "1", "B" => "2")))
156
+ .to eq %w{-e A=1 -e B=2}
157
+ end
158
+
159
+ it "strips surrounding whitespace from names and values" do
160
+ expect(argv(helper.build_env_variable_args(" A " => " 1 "))).to eq %w{-e A=1}
161
+ end
162
+
163
+ it "refuses anything that is not a Hash" do
164
+ # The driver would otherwise build "-e" flags out of an Array's elements
165
+ # and produce a command line that fails somewhere far from the cause.
166
+ expect { helper.build_env_variable_args(%w{A=1}) }
167
+ .to raise_error(Kitchen::ActionFailed, /not of a Hash type/)
168
+ end
169
+
170
+ it "keeps a value containing a space as one argument" do
171
+ expect(argv(helper.build_env_variable_args("MSG" => "hello world")))
172
+ .to eq ["-e", "MSG=hello world"]
173
+ end
174
+
175
+ it "keeps a value containing a double quote intact" do
176
+ expect(argv(helper.build_env_variable_args("MSG" => 'say "hi"')))
177
+ .to eq ["-e", 'MSG=say "hi"']
178
+ end
179
+
180
+ it "keeps a value containing a dollar sign out of the shell's reach" do
181
+ # An unescaped $HOME would be expanded by the shell running the docker
182
+ # command, so the container would receive the workstation's home
183
+ # directory instead of the literal string.
184
+ expect(argv(helper.build_env_variable_args("P" => "$HOME/x")))
185
+ .to eq ["-e", "P=$HOME/x"]
186
+ end
187
+
188
+ it "keeps a name containing a space as one argument" do
189
+ expect(argv(helper.build_env_variable_args("ODD NAME" => "v")))
190
+ .to eq ["-e", "ODD NAME=v"]
191
+ end
192
+ end
193
+
194
+ describe "#build_copy_command" do
195
+ it "copies local to remote" do
196
+ expect(argv(helper.build_copy_command("/tmp/a", "abc:/tmp/a")))
197
+ .to eq %w{cp /tmp/a abc:/tmp/a}
198
+ end
199
+
200
+ it "preserves ownership and mode when asked" do
201
+ expect(argv(helper.build_copy_command("/tmp/a", "abc:/tmp/a", archive: true)))
202
+ .to eq %w{cp -a /tmp/a abc:/tmp/a}
203
+ end
204
+
205
+ it "keeps a local path containing a space as one argument" do
206
+ expect(argv(helper.build_copy_command("/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb")))
207
+ .to eq ["cp", "/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb"]
208
+ end
209
+ end
210
+
211
+ describe "#config_to_options" do
212
+ subject { helper.config_to_options(input) }
213
+
214
+ context "with nil" do
215
+ let(:input) { nil }
216
+
217
+ it { is_expected.to eq "" }
218
+ end
219
+
220
+ context "with a string" do
221
+ let(:input) { "--foo" }
222
+
223
+ it { is_expected.to eq "--foo" }
224
+ end
225
+
226
+ context "with a string with spaces" do
227
+ let(:input) { "--foo bar" }
228
+
229
+ it { is_expected.to eq "--foo bar" }
230
+ end
231
+
232
+ context "with an array of strings" do
233
+ let(:input) { %w{--foo --bar} }
234
+
235
+ it { is_expected.to eq "--foo --bar" }
236
+ end
237
+
238
+ context "with an array of hashes" do
239
+ let(:input) { [{ foo: "bar" }, { other: "baz" }] }
240
+
241
+ it { is_expected.to eq "--foo=bar --other=baz" }
242
+ end
243
+
244
+ context "with a hash of strings" do
245
+ let(:input) { { foo: "bar", other: "baz" } }
246
+
247
+ it { is_expected.to eq "--foo=bar --other=baz" }
248
+ end
249
+
250
+ context "with a hash of arrays" do
251
+ let(:input) { { foo: %w{bar baz} } }
252
+
253
+ it { is_expected.to eq "--foo=bar --foo=baz" }
254
+ end
255
+
256
+ context "with a hash of strings with spaces" do
257
+ let(:input) { { foo: "bar two", other: "baz" } }
258
+
259
+ it { is_expected.to eq '--foo=bar\\ two --other=baz' }
260
+
261
+ it "survives shell splitting as one argument per flag" do
262
+ expect(argv(subject)).to eq ["--foo=bar two", "--other=baz"]
263
+ end
264
+ end
265
+
266
+ context "with a boolean value, as the README's build_options example uses" do
267
+ let(:input) { { rm: false } }
268
+
269
+ it { is_expected.to eq "--rm=false" }
270
+ end
271
+ end
272
+
273
+ describe "#run_command" do
274
+ it "returns stdout and stderr together" do
275
+ # docker writes build progress and image ids to stderr, so the driver
276
+ # reimplements run_command specifically to keep both streams.
277
+ expect(helper.run_command("echo out; echo err 1>&2")).to eq "out\nerr\n"
278
+ end
279
+
280
+ it "raises Kitchen::ShellOut::ShellCommandFailed when the command fails" do
281
+ # cli_helper raises a bare `ShellCommandFailed`, which resolves through
282
+ # the included Kitchen::ShellOut rather than being spelled out. Pin the
283
+ # class so a change to those includes cannot silently alter what callers
284
+ # have to rescue.
285
+ expect { helper.run_command("exit 7") }
286
+ .to raise_error(Kitchen::ShellOut::ShellCommandFailed, /exit with \[0\], but received '7'/)
287
+ end
288
+
289
+ it "prefixes the command with the sudo command when use_sudo is set" do
290
+ # Uses `echo` as the sudo command so the prefixing is observable without
291
+ # needing real sudo on the machine running the specs.
292
+ expect(helper.run_command("echo hi", use_sudo: true, sudo_command: "echo SUDO"))
293
+ .to eq "SUDO echo hi\n"
294
+ end
295
+
296
+ it "does not prefix anything when use_sudo is unset" do
297
+ expect(helper.run_command("echo hi")).to eq "hi\n"
298
+ end
299
+ end
300
+
301
+ describe "#docker_shell_opts" do
302
+ it "translates suppress_output into silencing the live stream" do
303
+ expect(helper.docker_shell_opts(suppress_output: true)).to eq(live_stream: nil)
304
+ end
305
+
306
+ it "removes suppress_output, which Mixlib::ShellOut would reject" do
307
+ expect(helper.docker_shell_opts(suppress_output: false)).not_to have_key(:suppress_output)
308
+ end
309
+
310
+ it "leaves other options alone" do
311
+ expect(helper.docker_shell_opts(timeout: 60)).to eq(timeout: 60)
312
+ end
313
+ end
314
+
315
+ describe "#dev_null" do
316
+ it "returns a device that exists on this platform" do
317
+ expect(helper.dev_null).to eq(Gem.win_platform? ? "NUL" : "/dev/null")
318
+ end
319
+ end
320
+ end