knife 17.2.18 → 17.4.18

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfdc3eb6fcda5623dfcc18f91ebb49ce954ad0601d07a2c8d4dfff3ef2a3ff09
4
- data.tar.gz: 68dabbc229978345b7b1057a890ca6936d6209a3040a245d700071e76ecccc0d
3
+ metadata.gz: 70c4e18afa9b4762387d8a0a6c9c75bca9e1e42723a041147bea36c2efaae176
4
+ data.tar.gz: '00953cd3c86ab1e11eb9ddb9a8fdf8fffbae40705671090169411877fa72a069'
5
5
  SHA512:
6
- metadata.gz: 19fabdab9e993be664662b6f7281ab8b81c65a75b2b7764a8945d48a31a301bd32e9abbda920b61fa7e764b5382ba6a97833827ad6e76f4049c57d768f231437
7
- data.tar.gz: d865c45c0532429a8939ac61c6ca5af5f378719a9cc178d2ad6cfca40008e09188b7bf9f75a522ef149a532cd7a7886ed1c9cc7a21c54c3156673b645185a856
6
+ metadata.gz: f2d2130d599b56a3d43bbbbe5f64407dfc19aaec9df4ea87af2b835b950dda18ef1b9eb6cee28fae9ee05bafef8b6fe180219b36f1f7fbe5333b4efcd3236002
7
+ data.tar.gz: 0d500fcfd9e9aa0830d04bae3f9d4e3712ab52bf6fcd9b87c4f0e3bbb2149f8568d179c41656189b7614bdbdd91e8dbd5d14800c38771275466c5e3c9e0c78f0
data/knife.gemspec CHANGED
@@ -30,6 +30,8 @@ Gem::Specification.new do |s|
30
30
  s.add_dependency "net-ssh-multi", "~> 1.2", ">= 1.2.1"
31
31
  s.add_dependency "ed25519", "~> 1.2" # ed25519 ssh key support
32
32
  s.add_dependency "bcrypt_pbkdf", "~> 1.1" # ed25519 ssh key support
33
+ # we can't use this gem until illegal instruction issues are resolved
34
+ # s.add_dependency "x25519" # ed25519 KEX module
33
35
  s.add_dependency "highline", ">= 1.6.9", "< 3" # Used in UI to present a list, no other usage.
34
36
 
35
37
  s.add_dependency "tty-prompt", "~> 0.21" # knife ui.ask prompt
@@ -20,6 +20,7 @@ require_relative "../knife"
20
20
  require_relative "data_bag_secret_options"
21
21
  require "chef-utils/dist" unless defined?(ChefUtils::Dist)
22
22
  require "license_acceptance/cli_flags/mixlib_cli"
23
+
23
24
  module LicenseAcceptance
24
25
  autoload :Acceptor, "license_acceptance/acceptor"
25
26
  end
@@ -705,6 +706,8 @@ class Chef
705
706
  ui.warn("#{e.message} - trying with pty request")
706
707
  conn_options[:pty] = true # ensure we can talk to systems with requiretty set true in sshd config
707
708
  retry
709
+ elsif e.reason == :sudo_missing_terminal
710
+ ui.error "Sudo password is required for this operation. Please enter password using -P or --ssh-password option"
708
711
  elsif config[:use_sudo_password] && (e.reason == :sudo_password_required || e.reason == :bad_sudo_password) && limit < 3
709
712
  ui.warn("Failed to authenticate #{conn_options[:user]} to #{server_name} - #{e.message} \n sudo: #{limit} incorrect password attempt")
710
713
  sudo_password = ui.ask("Enter sudo password for #{conn_options[:user]}@#{server_name}:", echo: false)
@@ -240,7 +240,7 @@ class Chef
240
240
 
241
241
  # Now that everything is populated, fill in anything missing
242
242
  # that may be found in user ssh config
243
- opts.merge!(missing_opts_from_ssh_config(opts, opts_in))
243
+ opts.merge!(missing_opts_from_ssh_config(opts))
244
244
 
245
245
  Train.target_config(opts)
246
246
  end
@@ -297,12 +297,12 @@ class Chef
297
297
  # in the configuration passed in.
298
298
  # This is necessary because train will default these values
299
299
  # itself - causing SSH config data to be ignored
300
- def missing_opts_from_ssh_config(config, opts_in)
300
+ def missing_opts_from_ssh_config(config)
301
301
  return {} unless config[:backend] == "ssh"
302
302
 
303
303
  host_cfg = ssh_config_for_host(config[:host])
304
304
  opts_out = {}
305
- opts_in.each do |key, _value|
305
+ host_cfg.each do |key, _value|
306
306
  if SSH_CONFIG_OVERRIDE_KEYS.include?(key) && !config.key?(key)
307
307
  opts_out[key] = host_cfg[key]
308
308
  end
@@ -81,6 +81,14 @@ class Chef
81
81
  client.public_key File.read(File.expand_path(config[:public_key]))
82
82
  end
83
83
 
84
+ # Check the file before creating the client so the api is more transactional.
85
+ if config[:file]
86
+ file = config[:file]
87
+ dir_name = File.dirname(file)
88
+ check_writable_or_exists(dir_name, "Directory")
89
+ check_writable_or_exists(file, "File")
90
+ end
91
+
84
92
  output = edit_hash(client)
85
93
  final_client = create_client(output)
86
94
  ui.info("Created #{final_client}")
@@ -96,6 +104,19 @@ class Chef
96
104
  end
97
105
  end
98
106
  end
107
+
108
+ # To check if file or directory exists or writable and raise exception accordingly
109
+ def check_writable_or_exists(file, type)
110
+ if File.exist?(file)
111
+ unless File.writable?(file)
112
+ ui.fatal "#{type} #{file} is not writable. Check permissions."
113
+ exit 1
114
+ end
115
+ else
116
+ ui.fatal "#{type} #{file} does not exist."
117
+ exit 1
118
+ end
119
+ end
99
120
  end
100
121
  end
101
122
  end
@@ -171,12 +171,12 @@ class Chef
171
171
  client_rb << "fips true\n"
172
172
  end
173
173
 
174
- unless chef_config[:file_cache_path].nil?
175
- client_rb << "file_cache_path \"#{chef_config[:file_cache_path]}\"\n"
174
+ unless chef_config[:unix_bootstrap_file_cache_path].nil?
175
+ client_rb << "file_cache_path \"#{chef_config[:unix_bootstrap_file_cache_path]}\"\n"
176
176
  end
177
177
 
178
- unless chef_config[:file_backup_path].nil?
179
- client_rb << "file_backup_path \"#{chef_config[:file_backup_path]}\"\n"
178
+ unless chef_config[:unix_bootstrap_file_backup_path].nil?
179
+ client_rb << "file_backup_path \"#{chef_config[:unix_bootstrap_file_backup_path]}\"\n"
180
180
  end
181
181
 
182
182
  client_rb
@@ -71,8 +71,8 @@ class Chef
71
71
  client_rb = <<~CONFIG
72
72
  chef_server_url "#{chef_config[:chef_server_url]}"
73
73
  validation_client_name "#{chef_config[:validation_client_name]}"
74
- file_cache_path "#{ChefConfig::PathHelper.escapepath(ChefConfig::Config.var_chef_dir(windows: true))}\\\\cache"
75
- file_backup_path "#{ChefConfig::PathHelper.escapepath(ChefConfig::Config.var_chef_dir(windows: true))}\\\\backup"
74
+ file_cache_path "#{ChefConfig::PathHelper.escapepath(chef_config[:windows_bootstrap_file_cache_path] || "")}"
75
+ file_backup_path "#{ChefConfig::PathHelper.escapepath(chef_config[:windows_bootstrap_file_backup_path] || "")}"
76
76
  cache_options ({:path => "#{ChefConfig::PathHelper.escapepath(ChefConfig::Config.etc_chef_dir(windows: true))}\\\\cache\\\\checksums", :skip_expires => true})
77
77
  CONFIG
78
78
 
@@ -86,8 +86,8 @@ class Chef
86
86
  client_rb << "# Using default node name (fqdn)\n"
87
87
  end
88
88
 
89
- if config[:config_log_level]
90
- client_rb << %Q{log_level :#{config[:config_log_level]}\n}
89
+ if chef_config[:config_log_level]
90
+ client_rb << %Q{log_level :#{chef_config[:config_log_level]}\n}
91
91
  else
92
92
  client_rb << "log_level :auto\n"
93
93
  end
@@ -134,6 +134,18 @@ class Chef
134
134
  boolean: true,
135
135
  default: false
136
136
 
137
+ option :pty,
138
+ long: "--[no-]pty",
139
+ description: "Request a PTY, enabled by default.",
140
+ boolean: true,
141
+ default: true
142
+
143
+ option :require_pty,
144
+ long: "--[no-]require-pty",
145
+ description: "Raise exception if a PTY cannot be acquired, disabled by default.",
146
+ boolean: true,
147
+ default: false
148
+
137
149
  def session
138
150
  ssh_error_handler = Proc.new do |server|
139
151
  if config[:on_error]
@@ -353,26 +365,26 @@ class Chef
353
365
  ui.msg(str)
354
366
  end
355
367
 
356
- def ssh_command(command, subsession = nil)
368
+ # @param command [String] the command to run
369
+ # @param session_list [???] list of sessions, one per node
370
+ #
371
+ def ssh_command(command, session_list = session)
372
+ stderr = ""
357
373
  exit_status = 0
358
- subsession ||= session
359
374
  command = fixup_sudo(command)
360
375
  command.force_encoding("binary") if command.respond_to?(:force_encoding)
361
- begin
362
- open_session(subsession, command)
363
- rescue => e
364
- open_session(subsession, command, true)
365
- end
366
- end
367
-
368
- def open_session(subsession, command, pty = false)
369
- stderr = ""
370
- exit_status = 0
371
- subsession.open_channel do |chan|
376
+ session_list.open_channel do |chan|
372
377
  if config[:on_error] && exit_status != 0
373
378
  chan.close
374
379
  else
375
- chan.request_pty if pty
380
+ if config[:pty]
381
+ chan.request_pty do |ch, success|
382
+ unless success
383
+ ui.warn("Failed to obtain a PTY from #{ch.connection.host}")
384
+ raise ArgumentError, "Request for PTY failed" if config[:require_pty]
385
+ end
386
+ end
387
+ end
376
388
  chan.exec command do |ch, success|
377
389
  raise ArgumentError, "Cannot execute #{command}" unless success
378
390
 
@@ -383,13 +395,11 @@ class Chef
383
395
  ichannel.send_data("#{get_password}\n")
384
396
  end
385
397
  end
386
-
387
398
  ch.on_extended_data do |_, _type, data|
388
- raise ArgumentError if data.eql?("sudo: no tty present and no askpass program specified\n")
399
+ raise ArgumentError, "No PTY present. If a PTY is required use --require-pty" if data.eql?("sudo: no tty present and no askpass program specified\n")
389
400
 
390
401
  stderr += data
391
402
  end
392
-
393
403
  ch.on_request "exit-status" do |ichannel, data|
394
404
  exit_status = [exit_status, data.read_long].max
395
405
  end
@@ -398,6 +408,8 @@ class Chef
398
408
  end
399
409
  session.loop
400
410
  exit_status
411
+ ensure
412
+ session_list.close
401
413
  end
402
414
 
403
415
  def get_password
@@ -50,7 +50,8 @@ class Chef
50
50
  rescue Net::HTTPClientException => e
51
51
  raise e unless /Forbidden/.match?(e.message)
52
52
 
53
- ui.error "Forbidden: You must be the maintainer of #{@cookbook_name} to unshare it."
53
+ ui.error "Forbidden: You must be the maintainer of #{@cookbook_name} to unshare it & #{config[:supermarket_site]} must allow maintainers to unshare cookbooks."
54
+ ui.warn "The default supermarket #{default_config[:supermarket_site]} does not allow maintainers to unshare cookbooks."
54
55
  exit 1
55
56
  end
56
57
 
@@ -17,7 +17,7 @@
17
17
  class Chef
18
18
  class Knife
19
19
  KNIFE_ROOT = File.expand_path("../..", __dir__)
20
- VERSION = "17.2.18".freeze
20
+ VERSION = "17.4.18".freeze
21
21
  end
22
22
  end
23
23
 
File without changes
@@ -50,6 +50,7 @@ describe "knife client create", :workstation do
50
50
 
51
51
  it "saves the private key to a file" do
52
52
  Dir.mktmpdir do |tgt|
53
+ File.open("#{tgt}/bah.pem", "w") { |pub| pub.write("test key") }
53
54
  knife("client create -f #{tgt}/bah.pem bah").should_succeed stderr: out
54
55
  expect(File).to exist("#{tgt}/bah.pem")
55
56
  end
@@ -0,0 +1,55 @@
1
+ #
2
+ # Author:: Marc Pardise (<marc@chef.io>)
3
+ # Copyright:: Copyright (c) Chef Software Inc.
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ require "chef/knife"
19
+
20
+ Chef::Knife.subcommand_loader.load_commands
21
+ commands = Chef::Knife::SubcommandLoader.generate_hash["_autogenerated_command_paths"]["plugins_paths"].keys
22
+
23
+ # Directly execute each support knife command
24
+ context "Command Sanity Check: executing ", :workstation do
25
+ describe "bundle exec knife" do
26
+ commands.each do |command|
27
+ command_name = command.gsub("_", " ")
28
+ modified_command, expected_result = case command_name
29
+ when /knife/
30
+ # because rspec is the actual executable running, the option parser error message
31
+ # is invalid from within the test.
32
+ next
33
+ when /config (use|get|list) profile.*/
34
+ # hyphenated special cases
35
+ [command_name, /^USAGE: knife config #{$1}-profile.*/]
36
+ when /(role|node|env) (env )?run list(.*)/
37
+ # underscored special cases...
38
+ env_part = $2.nil? ? "" : "env_"
39
+ ["#{$1} #{$2}run_list#{$3}", /^USAGE: knife #{$1} #{env_part}run_list#{$3}.*/]
40
+ else
41
+ [ command_name, /^USAGE: knife #{command_name}.*/]
42
+ end
43
+
44
+ # By using bundle exec knife instead of directly loading the command class or using the knife() helper,
45
+ # we ensure that this is a valid end-to-end test. This operates on the assumption
46
+ # that we continue to require the command class to be fully loaded so that it can handle the parsing of
47
+ # its own options.
48
+ full_command = "#{modified_command} --invalid-option outputs usage for '#{modified_command}' to stdout"
49
+ it full_command do
50
+ result = `bundle exec knife #{full_command}`
51
+ expect(result).to match(expected_result)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -66,7 +66,7 @@ describe "knife cookbook download", :workstation do
66
66
  Downloading root_files
67
67
  Cookbook downloaded to #{tmpdir}/x-1.0.1
68
68
  EOM
69
- )
69
+ )
70
70
  end
71
71
  end
72
72
  end
@@ -1307,7 +1307,7 @@ describe Chef::Knife::Bootstrap do
1307
1307
  context "when no identity file is specified" do
1308
1308
  it "generates the expected configuration (no keys, keys_only false)" do
1309
1309
  expect(knife.ssh_identity_opts).to eq( {
1310
- key_files: [ ],
1310
+ key_files: [],
1311
1311
  keys_only: false,
1312
1312
  })
1313
1313
  end
@@ -2050,6 +2050,19 @@ describe Chef::Knife::Bootstrap do
2050
2050
  expect { knife.do_connect({}) }.to raise_error(expected_error)
2051
2051
  end
2052
2052
  end
2053
+
2054
+ context "when a train sudo error is thrown for missing terminal" do
2055
+ let(:ui_error_msg) { "Sudo password is required for this operation. Please enter password using -P or --ssh-password option" }
2056
+ let(:expected_error) { Train::UserError.new(ui_error_msg, :sudo_missing_terminal) }
2057
+ before do
2058
+ allow(connection).to receive(:connect!).and_raise(expected_error)
2059
+ end
2060
+ it "outputs user friendly error message" do
2061
+ expect { knife.do_connect({}) }.not_to raise_error
2062
+ expect(stderr.string).to include(ui_error_msg)
2063
+ end
2064
+ end
2065
+
2053
2066
  end
2054
2067
 
2055
2068
  describe "validate_winrm_transport_opts!" do
@@ -122,10 +122,12 @@ describe Chef::Knife::ClientCreate do
122
122
  end
123
123
 
124
124
  it "should write the private key to a file" do
125
- knife.config[:file] = "/tmp/monkeypants"
125
+ file = Tempfile.new
126
+ file_path = file.path
127
+ knife.config[:file] = file_path
126
128
  filehandle = double("Filehandle")
127
129
  expect(filehandle).to receive(:print).with("woot")
128
- expect(File).to receive(:open).with("/tmp/monkeypants", "w").and_yield(filehandle)
130
+ expect(File).to receive(:open).with(file_path, "w").and_yield(filehandle)
129
131
  knife.run
130
132
  end
131
133
  end
@@ -164,6 +166,39 @@ describe Chef::Knife::ClientCreate do
164
166
  expect(client.validator).to be_truthy
165
167
  end
166
168
  end
169
+
170
+ describe "with -f or --file when dir or file is not writable or does not exists" do
171
+ let(:dir_path) { File.expand_path(File.join(CHEF_SPEC_DATA, "knife", "temp_dir")) }
172
+ let(:file_path) { File.expand_path(File.join(dir_path, "tmp.pem")) }
173
+
174
+ it "when the directory does not exists" do
175
+ knife.config[:file] = "example/client1.pem"
176
+ expect(knife.ui).to receive(:fatal).with("Directory example does not exist.")
177
+ expect { knife.run }.to raise_error(SystemExit)
178
+ end
179
+
180
+ it "when the directory not writable" do
181
+ knife.config[:file] = file_path
182
+ File.chmod(777, dir_path)
183
+ expect(knife.ui).to receive(:fatal).with("Directory #{dir_path} is not writable. Check permissions.")
184
+ expect { knife.run }.to raise_error(SystemExit)
185
+ end
186
+
187
+ it "when the file does not exists" do
188
+ path = "#{dir_path}/client1.pem"
189
+ knife.config[:file] = path
190
+ File.chmod(0755, dir_path)
191
+ expect(knife.ui).to receive(:fatal).with("File #{path} does not exist.")
192
+ expect { knife.run }.to raise_error(SystemExit)
193
+ end
194
+
195
+ it "when the file is not writable" do
196
+ knife.config[:file] = file_path
197
+ File.chmod(777, file_path)
198
+ expect(knife.ui).to receive(:fatal).with("File #{file_path} is not writable. Check permissions.")
199
+ expect { knife.run }.to raise_error(SystemExit)
200
+ end
201
+ end
167
202
  end
168
203
  end
169
204
  end
@@ -80,16 +80,16 @@ describe Chef::Knife::Core::BootstrapContext do
80
80
  end
81
81
  end
82
82
 
83
- describe "when file_cache_path is set" do
84
- let(:chef_config) { { file_cache_path: "/home/opscode/cache" } }
85
- it "sets file_cache_path in the generated config file" do
83
+ describe "when unix_bootstrap_file_cache_path is set" do
84
+ let(:chef_config) { { unix_bootstrap_file_cache_path: "/home/opscode/cache" } }
85
+ it "sets unix_bootstrap_file_cache_path in the generated config file" do
86
86
  expect(bootstrap_context.config_content).to include("file_cache_path \"/home/opscode/cache\"")
87
87
  end
88
88
  end
89
89
 
90
- describe "when file_backup_path is set" do
91
- let(:chef_config) { { file_backup_path: "/home/opscode/backup" } }
92
- it "sets file_backup_path in the generated config file" do
90
+ describe "when unix_bootstrap_file_backup_path is set" do
91
+ let(:chef_config) { { unix_bootstrap_file_backup_path: "/home/opscode/backup" } }
92
+ it "sets unix_bootstrap_file_backup_path in the generated config file" do
93
93
  expect(bootstrap_context.config_content).to include("file_backup_path \"/home/opscode/backup\"")
94
94
  end
95
95
  end
@@ -154,8 +154,8 @@ describe Chef::Knife::Core::WindowsBootstrapContext do
154
154
  config_log_location: STDOUT,
155
155
  chef_server_url: "http://chef.example.com:4444",
156
156
  validation_client_name: "chef-validator-testing",
157
- file_cache_path: "c:/chef/cache",
158
- file_backup_path: "c:/chef/backup",
157
+ windows_bootstrap_file_cache_path: "c:/chef/cache",
158
+ windows_bootstrap_file_backup_path: "c:/chef/backup",
159
159
  cache_options: ({ path: "c:/chef/cache/checksums", skip_expires: true })
160
160
  )
161
161
  )
@@ -165,11 +165,11 @@ describe Chef::Knife::Core::WindowsBootstrapContext do
165
165
  expected = <<~EXPECTED
166
166
  echo.chef_server_url "http://chef.example.com:4444"
167
167
  echo.validation_client_name "chef-validator-testing"
168
- echo.file_cache_path "C:\\\\chef\\\\cache"
169
- echo.file_backup_path "C:\\\\chef\\\\backup"
168
+ echo.file_cache_path "c:/chef/cache"
169
+ echo.file_backup_path "c:/chef/backup"
170
170
  echo.cache_options ^({:path =^> "C:\\\\chef\\\\cache\\\\checksums", :skip_expires =^> true}^)
171
171
  echo.# Using default node name ^(fqdn^)
172
- echo.log_level :auto
172
+ echo.log_level :info
173
173
  echo.log_location STDOUT
174
174
  EXPECTED
175
175
  expect(bootstrap_context.config_content).to eq expected
@@ -289,7 +289,7 @@ describe Chef::Knife::Ssh do
289
289
  let(:execution_channel2) { double(:execution_channel, on_data: nil, on_extended_data: nil) }
290
290
  let(:session_channel2) { double(:session_channel, request_pty: nil) }
291
291
 
292
- let(:session) { double(:session, loop: nil) }
292
+ let(:session) { double(:session, loop: nil, close: nil) }
293
293
 
294
294
  let(:command) { "false" }
295
295
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: knife
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.2.18
4
+ version: 17.4.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Jacob
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-06-02 00:00:00.000000000 Z
11
+ date: 2021-08-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: chef-config
@@ -751,6 +751,7 @@ files:
751
751
  - spec/data/kitchen/openldap/recipes/woot.rb
752
752
  - spec/data/knife-home/.chef/plugins/knife/example_home_subcommand.rb
753
753
  - spec/data/knife-site-subcommands/plugins/knife/example_subcommand.rb
754
+ - spec/data/knife/temp_dir/tmp.pem
754
755
  - spec/data/knife_subcommand/test_explicit_category.rb
755
756
  - spec/data/knife_subcommand/test_name_mapping.rb
756
757
  - spec/data/knife_subcommand/test_yourself.rb
@@ -950,6 +951,7 @@ files:
950
951
  - spec/integration/client_key_show_spec.rb
951
952
  - spec/integration/client_list_spec.rb
952
953
  - spec/integration/client_show_spec.rb
954
+ - spec/integration/commands_spec.rb
953
955
  - spec/integration/common_options_spec.rb
954
956
  - spec/integration/config_list_spec.rb
955
957
  - spec/integration/config_show_spec.rb
@@ -1148,7 +1150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
1148
1150
  - !ruby/object:Gem::Version
1149
1151
  version: '0'
1150
1152
  requirements: []
1151
- rubygems_version: 3.2.15
1153
+ rubygems_version: 3.2.22
1152
1154
  signing_key:
1153
1155
  specification_version: 4
1154
1156
  summary: The knife CLI for Chef Infra.