leap_cli 1.5.6 → 1.6.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. data/bin/leap +29 -6
  2. data/lib/leap/platform.rb +36 -1
  3. data/lib/leap_cli/commands/ca.rb +97 -20
  4. data/lib/leap_cli/commands/compile.rb +49 -8
  5. data/lib/leap_cli/commands/db.rb +13 -4
  6. data/lib/leap_cli/commands/deploy.rb +138 -29
  7. data/lib/leap_cli/commands/env.rb +76 -0
  8. data/lib/leap_cli/commands/facts.rb +10 -3
  9. data/lib/leap_cli/commands/inspect.rb +2 -2
  10. data/lib/leap_cli/commands/list.rb +10 -10
  11. data/lib/leap_cli/commands/node.rb +7 -132
  12. data/lib/leap_cli/commands/node_init.rb +169 -0
  13. data/lib/leap_cli/commands/pre.rb +4 -27
  14. data/lib/leap_cli/commands/ssh.rb +152 -0
  15. data/lib/leap_cli/commands/test.rb +22 -13
  16. data/lib/leap_cli/commands/user.rb +12 -4
  17. data/lib/leap_cli/commands/vagrant.rb +4 -4
  18. data/lib/leap_cli/config/filter.rb +175 -0
  19. data/lib/leap_cli/config/manager.rb +130 -61
  20. data/lib/leap_cli/config/node.rb +32 -0
  21. data/lib/leap_cli/config/object.rb +69 -44
  22. data/lib/leap_cli/config/object_list.rb +44 -39
  23. data/lib/leap_cli/config/secrets.rb +24 -12
  24. data/lib/leap_cli/config/tag.rb +7 -0
  25. data/lib/{core_ext → leap_cli/core_ext}/boolean.rb +0 -0
  26. data/lib/{core_ext → leap_cli/core_ext}/hash.rb +0 -0
  27. data/lib/{core_ext → leap_cli/core_ext}/json.rb +0 -0
  28. data/lib/{core_ext → leap_cli/core_ext}/nil.rb +0 -0
  29. data/lib/{core_ext → leap_cli/core_ext}/string.rb +0 -0
  30. data/lib/leap_cli/core_ext/yaml.rb +29 -0
  31. data/lib/leap_cli/exceptions.rb +24 -0
  32. data/lib/leap_cli/leapfile.rb +60 -10
  33. data/lib/{lib_ext → leap_cli/lib_ext}/capistrano_connections.rb +0 -0
  34. data/lib/{lib_ext → leap_cli/lib_ext}/gli.rb +0 -0
  35. data/lib/leap_cli/log.rb +1 -1
  36. data/lib/leap_cli/logger.rb +18 -1
  37. data/lib/leap_cli/markdown_document_listener.rb +1 -1
  38. data/lib/leap_cli/override/json.rb +11 -0
  39. data/lib/leap_cli/path.rb +20 -6
  40. data/lib/leap_cli/remote/leap_plugin.rb +2 -2
  41. data/lib/leap_cli/remote/puppet_plugin.rb +1 -1
  42. data/lib/leap_cli/remote/rsync_plugin.rb +1 -1
  43. data/lib/leap_cli/remote/tasks.rb +1 -1
  44. data/lib/leap_cli/ssh_key.rb +63 -1
  45. data/lib/leap_cli/util/remote_command.rb +19 -2
  46. data/lib/leap_cli/util/secret.rb +1 -1
  47. data/lib/leap_cli/util/x509.rb +3 -2
  48. data/lib/leap_cli/util.rb +11 -3
  49. data/lib/leap_cli/version.rb +2 -2
  50. data/lib/leap_cli.rb +24 -14
  51. data/vendor/certificate_authority/lib/certificate_authority/certificate.rb +85 -29
  52. data/vendor/certificate_authority/lib/certificate_authority/distinguished_name.rb +5 -0
  53. data/vendor/certificate_authority/lib/certificate_authority/extensions.rb +406 -41
  54. data/vendor/certificate_authority/lib/certificate_authority/key_material.rb +0 -34
  55. data/vendor/certificate_authority/lib/certificate_authority/serial_number.rb +6 -0
  56. data/vendor/certificate_authority/lib/certificate_authority/signing_request.rb +36 -1
  57. metadata +25 -24
  58. data/lib/leap_cli/commands/shell.rb +0 -89
  59. data/lib/leap_cli/config/macros.rb +0 -430
  60. data/lib/leap_cli/constants.rb +0 -7
  61. data/lib/leap_cli/requirements.rb +0 -19
  62. data/lib/lib_ext/markdown_document_listener.rb +0 -122
data/bin/leap CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- if ARGV.include?('--debug')
4
- require 'debugger'
3
+ if ARGV.include?('--debug') || ARGV.include?('-d')
4
+ DEBUG=true
5
+ begin
6
+ require 'debugger'
7
+ rescue LoadError
8
+ end
9
+ else
10
+ DEBUG=false
5
11
  end
6
12
 
7
13
  begin
@@ -18,7 +24,6 @@ rescue LoadError
18
24
  # This allows you to run the command directly while developing the gem, and also lets you
19
25
  # run from anywhere (I like to link 'bin/leap' to /usr/local/bin/leap).
20
26
  #
21
- require 'rubygems'
22
27
  base_dir = File.expand_path('..', File.dirname(File.symlink?(__FILE__) ? File.readlink(__FILE__) : __FILE__))
23
28
  require File.join(base_dir, 'lib','leap_cli','load_paths')
24
29
  require 'leap_cli'
@@ -27,7 +32,7 @@ end
27
32
  require 'gli'
28
33
  require 'highline'
29
34
  require 'forwardable'
30
- require 'lib_ext/gli' # our custom extensions to gli
35
+ require 'leap_cli/lib_ext/gli' # our custom extensions to gli
31
36
 
32
37
  #
33
38
  # Typically, GLI and Highline methods are loaded into the global namespace.
@@ -78,9 +83,27 @@ module LeapCli::Commands
78
83
  exit(0)
79
84
  end
80
85
 
86
+ # disable GLI error catching
87
+ ENV['GLI_DEBUG'] = "true"
88
+ def error_message(msg)
89
+ end
90
+
81
91
  # load commands and run
82
92
  commands_from('leap_cli/commands')
83
93
  ORIGINAL_ARGV = ARGV.dup
84
- exit_status = run(ARGV)
85
- exit(LeapCli::Util.exit_status || exit_status)
94
+ begin
95
+ exit_status = run(ARGV)
96
+ exit(LeapCli::Util.exit_status || exit_status)
97
+ rescue StandardError => exc
98
+ if exc.respond_to? :log
99
+ exc.log
100
+ else
101
+ puts
102
+ LeapCli.log :error, "%s: %s" % [exc.class, exc.message]
103
+ puts
104
+ end
105
+ if DEBUG
106
+ raise exc
107
+ end
108
+ end
86
109
  end
data/lib/leap/platform.rb CHANGED
@@ -16,10 +16,25 @@ module Leap
16
16
  attr_accessor :monitor_username
17
17
  attr_accessor :reserved_usernames
18
18
 
19
+ attr_accessor :hiera_path
20
+ attr_accessor :files_dir
21
+ attr_accessor :leap_dir
22
+ attr_accessor :init_path
23
+
24
+ attr_accessor :default_puppet_tags
25
+
19
26
  def define(&block)
20
- # some sanity defaults:
27
+ # some defaults:
21
28
  @reserved_usernames = []
29
+ @hiera_path = '/etc/leap/hiera.yaml'
30
+ @leap_dir = '/srv/leap'
31
+ @files_dir = '/srv/leap/files'
32
+ @init_path = '/srv/leap/initialized'
33
+ @default_puppet_tags = []
34
+
22
35
  self.instance_eval(&block)
36
+
37
+ @version ||= Versionomy.parse("0.0")
23
38
  end
24
39
 
25
40
  def version=(version)
@@ -44,10 +59,30 @@ module Leap
44
59
  # return true if the platform version is within the specified range.
45
60
  #
46
61
  def version_in_range?(range)
62
+ if range.is_a? String
63
+ range = range.split('..')
64
+ end
47
65
  minimum_platform_version = Versionomy.parse(range.first)
48
66
  maximum_platform_version = Versionomy.parse(range.last)
49
67
  @version >= minimum_platform_version && @version <= maximum_platform_version
50
68
  end
69
+
70
+ def major_version
71
+ if @version.major == 0
72
+ "#{@version.major}.#{@version.minor}"
73
+ else
74
+ @version.major
75
+ end
76
+ end
77
+
78
+ def method_missing(method, *args)
79
+ puts
80
+ puts "WARNING:"
81
+ puts " leap_cli is out of date and does not understand `#{method}`."
82
+ puts " called from: #{caller.first}"
83
+ puts " please upgrade to a newer leap_cli"
84
+ end
85
+
51
86
  end
52
87
 
53
88
  end
@@ -1,6 +1,6 @@
1
- require 'openssl'
2
- require 'certificate_authority'
3
- require 'date'
1
+ autoload :OpenSSL, 'openssl'
2
+ autoload :CertificateAuthority, 'certificate_authority'
3
+ autoload :Date, 'date'
4
4
  require 'digest/md5'
5
5
 
6
6
  module LeapCli; module Commands
@@ -36,6 +36,7 @@ module LeapCli; module Commands
36
36
 
37
37
  nodes = manager.filter!(args)
38
38
  nodes.each_node do |node|
39
+ warn_if_commercial_cert_will_soon_expire(node)
39
40
  if !node.x509.use
40
41
  remove_file!([:node_x509_key, node.name])
41
42
  remove_file!([:node_x509_cert, node.name])
@@ -81,9 +82,19 @@ module LeapCli; module Commands
81
82
  # http://www.redkestrel.co.uk/Articles/CSR.html
82
83
  #
83
84
  cert.desc "Creates a CSR for use in buying a commercial X.509 certificate."
84
- cert.long_desc "Unless specified, the CSR is created for the provider's primary domain. The properties used for this CSR come from `provider.ca.server_certificates`."
85
+ cert.long_desc "Unless specified, the CSR is created for the provider's primary domain. "+
86
+ "The properties used for this CSR come from `provider.ca.server_certificates`, "+
87
+ "but may be overridden here."
85
88
  cert.command :csr do |csr|
86
89
  csr.flag 'domain', :arg_name => 'DOMAIN', :desc => 'Specify what domain to create the CSR for.'
90
+ csr.flag ['organization', 'O'], :arg_name => 'ORGANIZATION', :desc => "Override default O in distinguished name."
91
+ csr.flag ['unit', 'OU'], :arg_name => 'UNIT', :desc => "Set OU in distinguished name."
92
+ csr.flag 'email', :arg_name => 'EMAIL', :desc => "Set emailAddress in distinguished name."
93
+ csr.flag ['locality', 'L'], :arg_name => 'LOCALITY', :desc => "Set L in distinguished name."
94
+ csr.flag ['state', 'ST'], :arg_name => 'STATE', :desc => "Set ST in distinguished name."
95
+ csr.flag ['country', 'C'], :arg_name => 'COUNTRY', :desc => "Set C in distinguished name."
96
+ csr.flag :bits, :arg_name => 'BITS', :desc => "Override default certificate bit length"
97
+ csr.flag :digest, :arg_name => 'DIGEST', :desc => "Override default signature digest"
87
98
  csr.action do |global_options,options,args|
88
99
  assert_config! 'provider.domain'
89
100
  assert_config! 'provider.name'
@@ -97,24 +108,25 @@ module LeapCli; module Commands
97
108
 
98
109
  # RSA key
99
110
  keypair = CertificateAuthority::MemoryKeyMaterial.new
100
- log :generating, "%s bit RSA key" % server_certificates.bit_size do
101
- keypair.generate_key(server_certificates.bit_size)
111
+ bit_size = (options[:bits] || server_certificates.bit_size).to_i
112
+ log :generating, "%s bit RSA key" % bit_size do
113
+ keypair.generate_key(bit_size)
102
114
  write_file! [:commercial_key, domain], keypair.private_key.to_pem
103
115
  end
104
116
 
105
117
  # CSR
106
118
  dn = CertificateAuthority::DistinguishedName.new
107
- csr = CertificateAuthority::SigningRequest.new
108
- dn.common_name = domain
109
- dn.organization = provider.name[provider.default_language]
110
- dn.country = server_certificates['country'] # optional
111
- dn.state = server_certificates['state'] # optional
112
- dn.locality = server_certificates['locality'] # optional
113
-
114
- log :generating, "CSR with commonName => '%s', organization => '%s'" % [dn.common_name, dn.organization] do
115
- csr.distinguished_name = dn
116
- csr.key_material = keypair
117
- csr.digest = server_certificates.digest
119
+ dn.common_name = domain
120
+ dn.organization = options[:organization] || provider.name[provider.default_language]
121
+ dn.ou = options[:organizational_unit] # optional
122
+ dn.email_address = options[:email] # optional
123
+ dn.country = options[:country] || server_certificates['country'] # optional
124
+ dn.state = options[:state] || server_certificates['state'] # optional
125
+ dn.locality = options[:locality] || server_certificates['locality'] # optional
126
+
127
+ digest = options[:digest] || server_certificates.digest
128
+ log :generating, "CSR with #{digest} digest and #{print_dn(dn)}" do
129
+ csr = create_csr(dn, keypair, digest)
118
130
  request = csr.to_x509_csr
119
131
  write_file! [:commercial_csr, domain], csr.to_pem
120
132
  end
@@ -191,7 +203,7 @@ module LeapCli; module Commands
191
203
  return true
192
204
  else
193
205
  cert = load_certificate_file([:node_x509_cert, node.name])
194
- if cert.not_after < months_from_yesterday(1)
206
+ if cert.not_after < months_from_yesterday(2)
195
207
  log :updating, "cert for node '#{node.name}' because it will expire soon"
196
208
  return true
197
209
  end
@@ -208,11 +220,12 @@ module LeapCli; module Commands
208
220
  ips << $1 if value =~ /^IP Address:(.*)$/
209
221
  dns_names << $1 if value =~ /^DNS:(.*)$/
210
222
  end
223
+ dns_names.sort!
211
224
  if ips.first != node.ip_address
212
225
  log :updating, "cert for node '#{node.name}' because ip_address has changed (from #{ips.first} to #{node.ip_address})"
213
226
  return true
214
227
  elsif dns_names != dns_names_for_node(node)
215
- log :updating, "cert for node '#{node.name}' because domain name aliases have changed (from #{dns_names.inspect} to #{dns_names_for_node(node).inspect})"
228
+ log :updating, "cert for node '#{node.name}' because domain name aliases have changed\n from: #{dns_names.inspect}\n to: #{dns_names_for_node(node).inspect})"
216
229
  return true
217
230
  end
218
231
  end
@@ -221,6 +234,22 @@ module LeapCli; module Commands
221
234
  return false
222
235
  end
223
236
 
237
+ def warn_if_commercial_cert_will_soon_expire(node)
238
+ dns_names_for_node(node).each do |domain|
239
+ if file_exists?([:commercial_cert, domain])
240
+ cert = load_certificate_file([:commercial_cert, domain])
241
+ path = Path.relative_path([:commercial_cert, domain])
242
+ if cert.not_after < Time.now.utc
243
+ log :error, "the commercial certificate '#{path}' has EXPIRED! " +
244
+ "You should renew it with `leap cert csr --domain #{domain}`."
245
+ elsif cert.not_after < months_from_yesterday(2)
246
+ log :warning, "the commercial certificate '#{path}' will expire soon. "+
247
+ "You should renew it with `leap cert csr --domain #{domain}`."
248
+ end
249
+ end
250
+ end
251
+ end
252
+
224
253
  def generate_cert_for_node(node)
225
254
  return if node.x509.use == false
226
255
 
@@ -261,6 +290,43 @@ module LeapCli; module Commands
261
290
  yield cert.key_material.private_key.to_pem, cert.to_pem
262
291
  end
263
292
 
293
+ #
294
+ # creates a CSR and returns it.
295
+ # with the correct extReq attribute so that the CA
296
+ # doens't generate certs with extensions we don't want.
297
+ #
298
+ def create_csr(dn, keypair, digest)
299
+ csr = CertificateAuthority::SigningRequest.new
300
+ csr.distinguished_name = dn
301
+ csr.key_material = keypair
302
+ csr.digest = digest
303
+
304
+ # define extensions manually (library doesn't support setting these on CSRs)
305
+ extensions = []
306
+ extensions << CertificateAuthority::Extensions::BasicConstraints.new.tap {|basic|
307
+ basic.ca = false
308
+ }
309
+ extensions << CertificateAuthority::Extensions::KeyUsage.new.tap {|keyusage|
310
+ keyusage.usage = ["digitalSignature", "keyEncipherment"]
311
+ }
312
+ extensions << CertificateAuthority::Extensions::ExtendedKeyUsage.new.tap {|extkeyusage|
313
+ extkeyusage.usage = [ "serverAuth"]
314
+ }
315
+
316
+ # convert extensions to attribute 'extReq'
317
+ # aka "Requested Extensions"
318
+ factory = OpenSSL::X509::ExtensionFactory.new
319
+ attrval = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(
320
+ extensions.map{|e| factory.create_ext(e.openssl_identifier, e.to_s, e.critical)}
321
+ )])
322
+ attrs = [
323
+ OpenSSL::X509::Attribute.new("extReq", attrval),
324
+ ]
325
+ csr.attributes = attrs
326
+
327
+ return csr
328
+ end
329
+
264
330
  def ca_root
265
331
  @ca_root ||= begin
266
332
  load_certificate_file(:ca_cert, :ca_key)
@@ -381,8 +447,10 @@ module LeapCli; module Commands
381
447
  names = [node.domain.internal, node.domain.full]
382
448
  if node['dns'] && node.dns['aliases'] && node.dns.aliases.any?
383
449
  names += node.dns.aliases
384
- names.compact!
385
450
  end
451
+ names.compact!
452
+ names.sort!
453
+ names.uniq!
386
454
  return names
387
455
  end
388
456
 
@@ -403,6 +471,15 @@ module LeapCli; module Commands
403
471
  cert_serial_number(domain_name).to_s(36)
404
472
  end
405
473
 
474
+ # prints CertificateAuthority::DistinguishedName fields
475
+ def print_dn(dn)
476
+ fields = {}
477
+ [:common_name, :locality, :state, :country, :organization, :organizational_unit, :email_address].each do |attr|
478
+ fields[attr] = dn.send(attr) if dn.send(attr)
479
+ end
480
+ fields.inspect
481
+ end
482
+
406
483
  ##
407
484
  ## TIME HELPERS
408
485
  ##
@@ -3,11 +3,20 @@ module LeapCli
3
3
  module Commands
4
4
 
5
5
  desc "Compile generated files."
6
- command :compile do |c|
6
+ command [:compile, :c] do |c|
7
7
  c.desc 'Compiles node configuration files into hiera files used for deployment.'
8
+ c.arg_name 'ENVIRONMENT', :optional => true
8
9
  c.command :all do |all|
9
10
  all.action do |global_options,options,args|
10
- compile_hiera_files
11
+ environment = args.first
12
+ if !LeapCli.leapfile.environment.nil? && !environment.nil? && environment != LeapCli.leapfile.environment
13
+ bail! "You cannot specify an ENVIRONMENT argument while the environment is pinned."
14
+ end
15
+ if environment && manager.environment_names.include?(environment)
16
+ compile_hiera_files(manager.filter([environment]))
17
+ else
18
+ compile_hiera_files(manager.filter)
19
+ end
11
20
  end
12
21
  end
13
22
 
@@ -29,7 +38,10 @@ module LeapCli
29
38
 
30
39
  # export generated files
31
40
  manager.export_nodes(nodes)
32
- manager.export_secrets(nodes.nil?) # only do a "clean" export if we are examining all the nodes
41
+ # a "clean" export of secrets will also remove keys that are no longer used,
42
+ # but this should not be done if we are not examining all possible nodes.
43
+ clean_export = nodes.nil?
44
+ manager.export_secrets(clean_export)
33
45
  end
34
46
 
35
47
  def update_compiled_ssh_configs
@@ -50,14 +62,16 @@ module LeapCli
50
62
  # keys, and every monitor node has a copy of the private monitor key.
51
63
  #
52
64
  def generate_monitor_ssh_keys
53
- priv_key_file = :monitor_priv_key
54
- pub_key_file = :monitor_pub_key
65
+ priv_key_file = path(:monitor_priv_key)
66
+ pub_key_file = path(:monitor_pub_key)
55
67
  unless file_exists?(priv_key_file, pub_key_file)
56
- cmd = %(ssh-keygen -N '' -C 'monitor' -t ecdsa -b 521 -f '%s') % path(priv_key_file)
68
+ ensure_dir(File.dirname(priv_key_file))
69
+ ensure_dir(File.dirname(pub_key_file))
70
+ cmd = %(ssh-keygen -N '' -C 'monitor' -t rsa -b 4096 -f '%s') % priv_key_file
57
71
  assert_run! cmd
58
72
  if file_exists?(priv_key_file, pub_key_file)
59
- log :created, path(priv_key_file)
60
- log :created, path(pub_key_file)
73
+ log :created, priv_key_file
74
+ log :created, pub_key_file
61
75
  else
62
76
  log :failed, 'to create monitor ssh keys'
63
77
  end
@@ -75,6 +89,9 @@ module LeapCli
75
89
  if keys.empty?
76
90
  bail! "You must have at least one public SSH user key configured in order to proceed. See `leap help add-user`."
77
91
  end
92
+ if file_exists?(path(:monitor_pub_key))
93
+ keys << path(:monitor_pub_key)
94
+ end
78
95
  keys.sort.each do |keyfile|
79
96
  ssh_type, ssh_key = File.read(keyfile).strip.split(" ")
80
97
  buffer << ssh_type
@@ -87,6 +104,30 @@ module LeapCli
87
104
  write_file!(:authorized_keys, buffer.string)
88
105
  end
89
106
 
107
+ #
108
+ # generates the known_hosts file.
109
+ #
110
+ # we do a 'late' binding on the hostnames and ip part of the ssh pub key record in order to allow
111
+ # for the possibility that the hostnames or ip has changed in the node configuration.
112
+ #
113
+ def update_known_hosts
114
+ buffer = StringIO.new
115
+ buffer << "#\n"
116
+ buffer << "# This file is automatically generated by the command `leap`. You should NOT modify this file.\n"
117
+ buffer << "# Instead, rerun `leap node init` on whatever node is causing SSH problems.\n"
118
+ buffer << "#\n"
119
+ manager.nodes.keys.sort.each do |node_name|
120
+ node = manager.nodes[node_name]
121
+ hostnames = [node.name, node.domain.internal, node.domain.full, node.ip_address].join(',')
122
+ pub_key = read_file([:node_ssh_pub_key,node.name])
123
+ if pub_key
124
+ buffer << [hostnames, pub_key].join(' ')
125
+ buffer << "\n"
126
+ end
127
+ end
128
+ write_file!(:known_hosts, buffer.string)
129
+ end
130
+
90
131
  ##
91
132
  ## ZONE FILE
92
133
  ##
@@ -2,14 +2,23 @@ module LeapCli; module Commands
2
2
 
3
3
  desc 'Database commands.'
4
4
  command :db do |db|
5
- db.desc 'Destroy all the databases.'
5
+ db.desc 'Destroy all the databases. If present, limit to FILTER nodes.'
6
+ db.arg_name 'FILTER', :optional => true
6
7
  db.command :destroy do |destroy|
7
8
  destroy.action do |global_options,options,args|
8
9
  say 'You are about to permanently destroy all database data.'
9
10
  return unless agree("Continue? ")
10
- nodes = manager.nodes[:services => 'couchdb']
11
- ssh_connect(nodes, connect_options(options)) do |ssh|
12
- ssh.run('/etc/init.d/bigcouch stop && test ! -z "$(ls /opt/bigcouch/var/lib/ 2> /dev/null)" && rm -r /opt/bigcouch/var/lib/* && echo "db destroyed" || echo "db already destroyed"')
11
+ nodes = manager.filter(args)
12
+ if nodes.any?
13
+ nodes = nodes[:services => 'couchdb']
14
+ end
15
+ if nodes.any?
16
+ ssh_connect(nodes, connect_options(options)) do |ssh|
17
+ ssh.run('/etc/init.d/bigcouch stop && test ! -z "$(ls /opt/bigcouch/var/lib/ 2> /dev/null)" && rm -r /opt/bigcouch/var/lib/* && echo "db destroyed" || echo "db already destroyed"')
18
+ ssh.run('grep ^seq_file /etc/leap/tapicero.yaml | cut -f2 -d\" | xargs rm -v')
19
+ end
20
+ else
21
+ say 'No nodes'
13
22
  end
14
23
  end
15
24
  end