knife-essentials 0.9.2 → 0.9.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.
- data/lib/chef/knife/raw_essentials.rb +3 -61
- data/lib/chef_fs/data_handler/data_handler_base.rb +2 -1
- data/lib/chef_fs/file_system/base_fs_object.rb +75 -1
- data/lib/chef_fs/file_system/chef_repository_file_system_entry.rb +23 -0
- data/lib/chef_fs/file_system/cookbook_dir.rb +27 -13
- data/lib/chef_fs/file_system/cookbooks_dir.rb +89 -30
- data/lib/chef_fs/file_system/data_bags_dir.rb +5 -1
- data/lib/chef_fs/file_system/nodes_dir.rb +8 -14
- data/lib/chef_fs/file_system/rest_list_dir.rb +6 -2
- data/lib/chef_fs/file_system/rest_list_entry.rb +15 -3
- data/lib/chef_fs/knife.rb +28 -17
- data/lib/chef_fs/version.rb +1 -1
- data/spec/chef_fs/file_system/chef_server_root_dir_spec.rb +26 -12
- data/spec/chef_fs/file_system/cookbook_dir_spec.rb +582 -0
- data/spec/chef_fs/file_system/cookbooks_dir_spec.rb +81 -489
- data/spec/chef_fs/file_system/data_bags_dir_spec.rb +63 -49
- data/spec/integration/deps_spec.rb +32 -32
- data/spec/integration/diff_spec.rb +425 -133
- data/spec/integration/download_spec.rb +743 -211
- data/spec/integration/upload_spec.rb +814 -244
- data/spec/support/integration_helper.rb +33 -6
- data/spec/support/knife_support.rb +4 -2
- metadata +19 -2
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'json'
|
2
|
+
require 'chef_fs/data_handler/data_handler_base'
|
2
3
|
|
3
4
|
class Chef
|
4
5
|
class Knife
|
@@ -41,7 +42,7 @@ class Chef
|
|
41
42
|
end
|
42
43
|
chef_rest = Chef::REST.new(Chef::Config[:chef_server_url])
|
43
44
|
begin
|
44
|
-
output api_request(chef_rest, config[:method].to_sym, chef_rest.create_url(name_args[0]), {}, data)
|
45
|
+
output ChefFS::FileSystem::BaseFSObject.api_request(chef_rest, config[:method].to_sym, chef_rest.create_url(name_args[0]), {}, data)
|
45
46
|
rescue Net::HTTPServerException => e
|
46
47
|
ui.error "Server responded with error #{e.response.code} \"#{e.response.message}\""
|
47
48
|
ui.error "Error Body: #{e.response.body}" if e.response.body && e.response.body != ''
|
@@ -49,66 +50,7 @@ class Chef
|
|
49
50
|
end
|
50
51
|
end
|
51
52
|
|
52
|
-
|
53
|
-
ENCODING_GZIP_DEFLATE = "gzip;q=1.0,deflate;q=0.6,identity;q=0.3".freeze
|
54
|
-
|
55
|
-
def redirected_to(response)
|
56
|
-
return nil unless response.kind_of?(Net::HTTPRedirection)
|
57
|
-
# Net::HTTPNotModified is undesired subclass of Net::HTTPRedirection so test for this
|
58
|
-
return nil if response.kind_of?(Net::HTTPNotModified)
|
59
|
-
response['location']
|
60
|
-
end
|
61
|
-
|
62
|
-
def api_request(chef_rest, method, url, headers={}, data=false)
|
63
|
-
json_body = data
|
64
|
-
# json_body = data ? Chef::JSONCompat.to_json(data) : nil
|
65
|
-
# Force encoding to binary to fix SSL related EOFErrors
|
66
|
-
# cf. http://tickets.opscode.com/browse/CHEF-2363
|
67
|
-
# http://redmine.ruby-lang.org/issues/5233
|
68
|
-
# json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
|
69
|
-
headers = build_headers(chef_rest, method, url, headers, json_body)
|
70
|
-
|
71
|
-
chef_rest.retriable_rest_request(method, url, json_body, headers) do |rest_request|
|
72
|
-
response = rest_request.call {|r| r.read_body}
|
73
|
-
|
74
|
-
response_body = chef_rest.decompress_body(response)
|
75
|
-
|
76
|
-
if response.kind_of?(Net::HTTPSuccess)
|
77
|
-
if config[:pretty] && response['content-type'] =~ /json/
|
78
|
-
JSON.pretty_generate(JSON.parse(response_body, :create_additions => false))
|
79
|
-
else
|
80
|
-
response_body
|
81
|
-
end
|
82
|
-
elsif redirect_location = redirected_to(response)
|
83
|
-
raise "Redirected to #{create_url(redirect_location)}"
|
84
|
-
follow_redirect {api_request(:GET, create_url(redirect_location))}
|
85
|
-
else
|
86
|
-
# have to decompress the body before making an exception for it. But the body could be nil.
|
87
|
-
response.body.replace(chef_rest.decompress_body(response)) if response.body.respond_to?(:replace)
|
88
|
-
|
89
|
-
if response['content-type'] =~ /json/
|
90
|
-
exception = response_body
|
91
|
-
msg = "HTTP Request Returned #{response.code} #{response.message}: "
|
92
|
-
msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
|
93
|
-
Chef::Log.info(msg)
|
94
|
-
end
|
95
|
-
response.error!
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
def build_headers(chef_rest, method, url, headers={}, json_body=false, raw=false)
|
101
|
-
# headers = @default_headers.merge(headers)
|
102
|
-
#headers['Accept'] = "application/json" unless raw
|
103
|
-
headers['Accept'] = "application/json" unless raw
|
104
|
-
headers["Content-Type"] = 'application/json' if json_body
|
105
|
-
headers['Content-Length'] = json_body.bytesize.to_s if json_body
|
106
|
-
headers[Chef::REST::RESTRequest::ACCEPT_ENCODING] = Chef::REST::RESTRequest::ENCODING_GZIP_DEFLATE
|
107
|
-
headers.merge!(chef_rest.authentication_headers(method, url, json_body)) if chef_rest.sign_requests?
|
108
|
-
headers.merge!(Chef::Config[:custom_http_headers]) if Chef::Config[:custom_http_headers]
|
109
|
-
headers
|
110
|
-
end
|
111
|
-
end
|
53
|
+
end # class Raw
|
112
54
|
end
|
113
55
|
end
|
114
56
|
|
@@ -103,6 +103,11 @@ module ChefFS
|
|
103
103
|
[]
|
104
104
|
end
|
105
105
|
|
106
|
+
def chef_hash
|
107
|
+
raise NotFoundError.new(self) if !exists?
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
106
111
|
# Expand this entry into a chef object (Chef::Role, ::Node, etc.)
|
107
112
|
def chef_object
|
108
113
|
raise NotFoundError.new(self) if !exists?
|
@@ -171,7 +176,76 @@ module ChefFS
|
|
171
176
|
# Important directory attributes: name, parent, path, root
|
172
177
|
# Overridable attributes: dir?, child(name), path_for_printing
|
173
178
|
# Abstract: read, write, delete, children, can_have_child?, create_child, compare_to
|
174
|
-
|
179
|
+
|
180
|
+
# Consider putting this into a concern module and including it instead
|
181
|
+
def raw_request(_api_path)
|
182
|
+
self.class.api_request(rest, :GET, rest.create_url(_api_path), {}, false)
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
class << self
|
187
|
+
# Copied so that it does not automatically inflate an object
|
188
|
+
# This is also used by knife raw_essentials
|
189
|
+
|
190
|
+
ACCEPT_ENCODING = "Accept-Encoding".freeze
|
191
|
+
ENCODING_GZIP_DEFLATE = "gzip;q=1.0,deflate;q=0.6,identity;q=0.3".freeze
|
192
|
+
|
193
|
+
def redirected_to(response)
|
194
|
+
return nil unless response.kind_of?(Net::HTTPRedirection)
|
195
|
+
# Net::HTTPNotModified is undesired subclass of Net::HTTPRedirection so test for this
|
196
|
+
return nil if response.kind_of?(Net::HTTPNotModified)
|
197
|
+
response['location']
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
def build_headers(chef_rest, method, url, headers={}, json_body=false, raw=false)
|
202
|
+
# headers = @default_headers.merge(headers)
|
203
|
+
#headers['Accept'] = "application/json" unless raw
|
204
|
+
headers['Accept'] = "application/json" unless raw
|
205
|
+
headers["Content-Type"] = 'application/json' if json_body
|
206
|
+
headers['Content-Length'] = json_body.bytesize.to_s if json_body
|
207
|
+
headers[Chef::REST::RESTRequest::ACCEPT_ENCODING] = Chef::REST::RESTRequest::ENCODING_GZIP_DEFLATE
|
208
|
+
headers.merge!(chef_rest.authentication_headers(method, url, json_body)) if chef_rest.sign_requests?
|
209
|
+
headers.merge!(Chef::Config[:custom_http_headers]) if Chef::Config[:custom_http_headers]
|
210
|
+
headers
|
211
|
+
end
|
212
|
+
|
213
|
+
def api_request(chef_rest, method, url, headers={}, data=false)
|
214
|
+
json_body = data
|
215
|
+
# json_body = data ? Chef::JSONCompat.to_json(data) : nil
|
216
|
+
# Force encoding to binary to fix SSL related EOFErrors
|
217
|
+
# cf. http://tickets.opscode.com/browse/CHEF-2363
|
218
|
+
# http://redmine.ruby-lang.org/issues/5233
|
219
|
+
# json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
|
220
|
+
headers = build_headers(chef_rest, method, url, headers, json_body)
|
221
|
+
|
222
|
+
chef_rest.retriable_rest_request(method, url, json_body, headers) do |rest_request|
|
223
|
+
response = rest_request.call {|r| r.read_body}
|
224
|
+
|
225
|
+
response_body = chef_rest.decompress_body(response)
|
226
|
+
|
227
|
+
if response.kind_of?(Net::HTTPSuccess)
|
228
|
+
response_body
|
229
|
+
elsif redirect_location = redirected_to(response)
|
230
|
+
raise "Redirected to #{create_url(redirect_location)}"
|
231
|
+
follow_redirect {api_request(:GET, create_url(redirect_location))}
|
232
|
+
else
|
233
|
+
# have to decompress the body before making an exception for it. But the body could be nil.
|
234
|
+
response.body.replace(chef_rest.decompress_body(response)) if response.body.respond_to?(:replace)
|
235
|
+
|
236
|
+
if response['content-type'] =~ /json/
|
237
|
+
exception = response_body
|
238
|
+
msg = "HTTP Request Returned #{response.code} #{response.message}: "
|
239
|
+
msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
|
240
|
+
Chef::Log.info(msg)
|
241
|
+
end
|
242
|
+
response.error!
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
end # class BaseFsObject
|
175
249
|
end
|
176
250
|
end
|
177
251
|
|
@@ -1,5 +1,6 @@
|
|
1
1
|
#
|
2
2
|
# Author:: John Keiser (<jkeiser@opscode.com>)
|
3
|
+
# Author:: Ho-Sheng Hsiao (<hosh@opscode.com>)
|
3
4
|
# Copyright:: Copyright (c) 2012 Opscode, Inc.
|
4
5
|
# License:: Apache License, Version 2.0
|
5
6
|
#
|
@@ -45,6 +46,17 @@ module ChefFS
|
|
45
46
|
begin
|
46
47
|
if parent.path == '/cookbooks'
|
47
48
|
loader = Chef::Cookbook::CookbookVersionLoader.new(file_path, parent.chefignore)
|
49
|
+
# We need the canonical cookbook name if we are using versioned cookbooks, but we don't
|
50
|
+
# want to spend a lot of time adding code to the main Chef libraries
|
51
|
+
if Chef::Config[:versioned_cookbooks]
|
52
|
+
|
53
|
+
_canonical_name = canonical_cookbook_name(File.basename(file_path))
|
54
|
+
fail "When versioned_cookbooks mode is on, cookbook #{file_path} must match format <cookbook_name>-x.y.z" unless _canonical_name
|
55
|
+
|
56
|
+
# KLUDGE: We shouldn't have to use instance_variable_set
|
57
|
+
loader.instance_variable_set(:@cookbook_name, _canonical_name)
|
58
|
+
end
|
59
|
+
|
48
60
|
loader.load_cookbooks
|
49
61
|
return loader.cookbook_version
|
50
62
|
end
|
@@ -57,6 +69,17 @@ module ChefFS
|
|
57
69
|
nil
|
58
70
|
end
|
59
71
|
|
72
|
+
# Exposed as a class method so that it can be used elsewhere
|
73
|
+
def self.canonical_cookbook_name(entry_name)
|
74
|
+
name_match = ChefFS::FileSystem::CookbookDir::VALID_VERSIONED_COOKBOOK_NAME.match(entry_name)
|
75
|
+
return nil if name_match.nil?
|
76
|
+
return name_match[1]
|
77
|
+
end
|
78
|
+
|
79
|
+
def canonical_cookbook_name(entry_name)
|
80
|
+
self.class.canonical_cookbook_name(entry_name)
|
81
|
+
end
|
82
|
+
|
60
83
|
def children
|
61
84
|
@children ||=
|
62
85
|
Dir.entries(file_path).
|
@@ -26,12 +26,24 @@ require 'chef/cookbook_uploader'
|
|
26
26
|
module ChefFS
|
27
27
|
module FileSystem
|
28
28
|
class CookbookDir < BaseFSDir
|
29
|
-
def initialize(name, parent,
|
29
|
+
def initialize(name, parent, options = {})
|
30
30
|
super(name, parent)
|
31
|
-
@
|
31
|
+
@exists = options[:exists]
|
32
|
+
# If the name is apache2-1.0.0 and versioned_cookbooks is on, we know
|
33
|
+
# the actual cookbook_name and version.
|
34
|
+
if Chef::Config[:versioned_cookbooks]
|
35
|
+
if name =~ VALID_VERSIONED_COOKBOOK_NAME
|
36
|
+
@cookbook_name = $1
|
37
|
+
@version = $2
|
38
|
+
else
|
39
|
+
@exists = false
|
40
|
+
end
|
41
|
+
else
|
42
|
+
@cookbook_name = name
|
43
|
+
end
|
32
44
|
end
|
33
45
|
|
34
|
-
attr_reader :
|
46
|
+
attr_reader :cookbook_name, :version
|
35
47
|
|
36
48
|
COOKBOOK_SEGMENT_INFO = {
|
37
49
|
:attributes => { :ruby_only => true },
|
@@ -45,12 +57,16 @@ module ChefFS
|
|
45
57
|
:root_files => { }
|
46
58
|
}
|
47
59
|
|
60
|
+
# See Erchef code
|
61
|
+
# https://github.com/opscode/chef_objects/blob/968a63344d38fd507f6ace05f73d53e9cd7fb043/src/chef_regex.erl#L94
|
62
|
+
VALID_VERSIONED_COOKBOOK_NAME = /^([.a-zA-Z0-9_-]+)-(\d+\.\d+\.\d+)$/
|
63
|
+
|
48
64
|
def add_child(child)
|
49
65
|
@children << child
|
50
66
|
end
|
51
67
|
|
52
68
|
def api_path
|
53
|
-
"#{parent.api_path}/#{
|
69
|
+
"#{parent.api_path}/#{cookbook_name}/#{version || "_latest"}"
|
54
70
|
end
|
55
71
|
|
56
72
|
def child(name)
|
@@ -67,11 +83,8 @@ module ChefFS
|
|
67
83
|
|
68
84
|
def can_have_child?(name, is_dir)
|
69
85
|
# A cookbook's root may not have directories unless they are segment directories
|
70
|
-
if is_dir
|
71
|
-
|
72
|
-
COOKBOOK_SEGMENT_INFO.keys.any? { |segment| segment.to_s == name }
|
73
|
-
end
|
74
|
-
true
|
86
|
+
return name != 'root_files' && COOKBOOK_SEGMENT_INFO.keys.include?(name.to_sym) if is_dir
|
87
|
+
return true
|
75
88
|
end
|
76
89
|
|
77
90
|
def children
|
@@ -123,12 +136,13 @@ module ChefFS
|
|
123
136
|
end
|
124
137
|
end
|
125
138
|
|
139
|
+
# In versioned cookbook mode, actually check if the version exists
|
140
|
+
# Probably want to cache this.
|
126
141
|
def exists?
|
127
|
-
if
|
128
|
-
|
129
|
-
@versions = child.versions if child
|
142
|
+
if @exists.nil?
|
143
|
+
@exists = parent.children.any? { |child| child.name == name }
|
130
144
|
end
|
131
|
-
|
145
|
+
@exists
|
132
146
|
end
|
133
147
|
|
134
148
|
def compare_to(other)
|
@@ -19,6 +19,8 @@
|
|
19
19
|
require 'chef_fs/file_system/rest_list_dir'
|
20
20
|
require 'chef_fs/file_system/cookbook_dir'
|
21
21
|
|
22
|
+
require 'tmpdir'
|
23
|
+
|
22
24
|
module ChefFS
|
23
25
|
module FileSystem
|
24
26
|
class CookbooksDir < RestListDir
|
@@ -27,12 +29,32 @@ module ChefFS
|
|
27
29
|
end
|
28
30
|
|
29
31
|
def child(name)
|
30
|
-
|
31
|
-
|
32
|
+
if @children
|
33
|
+
result = self.children.select { |child| child.name == name }.first
|
34
|
+
if result
|
35
|
+
result
|
36
|
+
else
|
37
|
+
NonexistentFSObject.new(name, self)
|
38
|
+
end
|
39
|
+
else
|
40
|
+
CookbookDir.new(name, self)
|
41
|
+
end
|
32
42
|
end
|
33
43
|
|
34
44
|
def children
|
35
|
-
@children ||=
|
45
|
+
@children ||= begin
|
46
|
+
if Chef::Config[:versioned_cookbooks]
|
47
|
+
result = []
|
48
|
+
rest.get_rest("#{api_path}/?num_versions=all").each_pair do |cookbook_name, cookbooks|
|
49
|
+
cookbooks['versions'].each do |cookbook_version|
|
50
|
+
result << CookbookDir.new("#{cookbook_name}-#{cookbook_version['version']}", self, :exists => true)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
else
|
54
|
+
result = rest.get_rest(api_path).keys.map { |cookbook_name| CookbookDir.new(cookbook_name, self, :exists => true) }
|
55
|
+
end
|
56
|
+
result.sort_by(&:name)
|
57
|
+
end
|
36
58
|
end
|
37
59
|
|
38
60
|
def create_child_from(other)
|
@@ -40,38 +62,75 @@ module ChefFS
|
|
40
62
|
end
|
41
63
|
|
42
64
|
def upload_cookbook_from(other)
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
65
|
+
Chef::Config[:versioned_cookbooks] ? upload_versioned_cookbook(other) : upload_unversioned_cookbook(other)
|
66
|
+
rescue Net::HTTPServerException => e
|
67
|
+
case e.response.code
|
68
|
+
when "409"
|
69
|
+
ui.error "Version #{other_cookbook_version.version} of cookbook #{other_cookbook_version.name} is frozen. Use --force to override."
|
70
|
+
Chef::Log.debug(e)
|
71
|
+
raise Exceptions::CookbookFrozen
|
72
|
+
else
|
73
|
+
raise
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Knife currently does not understand versioned cookbooks
|
78
|
+
# Cookbook Version uploader also requires a lot of refactoring
|
79
|
+
# to make this work. So instead, we make a temporary cookbook
|
80
|
+
# symlinking back to real cookbook, and upload the proxy.
|
81
|
+
def upload_versioned_cookbook(other)
|
82
|
+
cookbook_name = ChefFS::FileSystem::ChefRepositoryFileSystemEntry.canonical_cookbook_name(other.name)
|
83
|
+
|
84
|
+
Dir.mktmpdir do |temp_cookbooks_path|
|
85
|
+
proxy_cookbook_path = "#{temp_cookbooks_path}/#{cookbook_name}"
|
86
|
+
|
87
|
+
# Make a symlink
|
88
|
+
File.symlink other.file_path, proxy_cookbook_path
|
89
|
+
|
90
|
+
# Instantiate a proxy loader using the temporary symlink
|
91
|
+
proxy_loader = Chef::Cookbook::CookbookVersionLoader.new(proxy_cookbook_path, other.parent.chefignore)
|
92
|
+
proxy_loader.load_cookbooks
|
93
|
+
|
94
|
+
# Instantiate a new uploader based on the proxy loader
|
95
|
+
uploader = Chef::CookbookUploader.new(proxy_loader.cookbook_version, proxy_cookbook_path, :rest => rest)
|
96
|
+
|
97
|
+
with_actual_cookbooks_dir(temp_cookbooks_path) do
|
98
|
+
upload_cookbook!(uploader)
|
69
99
|
end
|
70
100
|
end
|
71
101
|
end
|
72
102
|
|
103
|
+
def upload_unversioned_cookbook(other)
|
104
|
+
uploader = Chef::CookbookUploader.new(other.chef_object, other.parent.file_path, :rest => rest)
|
105
|
+
|
106
|
+
with_actual_cookbooks_dir(other.parent.file_path) do
|
107
|
+
upload_cookbook!(uploader)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Work around the fact that CookbookUploader doesn't understand chef_repo_path (yet)
|
112
|
+
def with_actual_cookbooks_dir(actual_cookbook_path)
|
113
|
+
old_cookbook_path = Chef::Config.cookbook_path
|
114
|
+
Chef::Config.cookbook_path = actual_cookbook_path if !Chef::Config.cookbook_path
|
115
|
+
|
116
|
+
yield
|
117
|
+
ensure
|
118
|
+
Chef::Config.cookbook_path = old_cookbook_path
|
119
|
+
end
|
120
|
+
|
121
|
+
# Chef 11 changes this API
|
122
|
+
def upload_cookbook!(uploader)
|
123
|
+
if uploader.respond_to?(:upload_cookbook)
|
124
|
+
uploader.upload_cookbook
|
125
|
+
else
|
126
|
+
uploader.upload_cookbooks
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
73
130
|
def can_have_child?(name, is_dir)
|
74
|
-
is_dir
|
131
|
+
return false if !is_dir
|
132
|
+
return false if Chef::Config[:versioned_cookbooks] && name !~ ChefFS::FileSystem::CookbookDir::VALID_VERSIONED_COOKBOOK_NAME
|
133
|
+
return true
|
75
134
|
end
|
76
135
|
end
|
77
136
|
end
|
@@ -33,7 +33,7 @@ module ChefFS
|
|
33
33
|
|
34
34
|
def children
|
35
35
|
begin
|
36
|
-
@children ||=
|
36
|
+
@children ||= chef_collection.keys.sort.map do |entry|
|
37
37
|
DataBagDir.new(entry, self, true)
|
38
38
|
end
|
39
39
|
rescue Net::HTTPServerException
|
@@ -45,6 +45,10 @@ module ChefFS
|
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
+
def chef_collection
|
49
|
+
rest.get_rest(api_path)
|
50
|
+
end
|
51
|
+
|
48
52
|
def can_have_child?(name, is_dir)
|
49
53
|
is_dir
|
50
54
|
end
|