bcome 1.4.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/lib/bcome.rb +7 -0
  3. data/lib/objects/bcome/version.rb +3 -3
  4. data/lib/objects/bootup.rb +4 -3
  5. data/lib/objects/driver/base.rb +16 -2
  6. data/lib/objects/driver/ec2.rb +11 -2
  7. data/lib/objects/driver/gcp.rb +49 -5
  8. data/lib/objects/driver/gcp/authentication/base.rb +36 -0
  9. data/lib/objects/driver/gcp/authentication/oauth.rb +24 -29
  10. data/lib/objects/driver/gcp/authentication/oauth_client_config.rb +22 -0
  11. data/lib/objects/driver/gcp/authentication/oauth_session_store.rb +22 -0
  12. data/lib/objects/driver/gcp/authentication/service_account.rb +57 -2
  13. data/lib/objects/driver/gcp/authentication/signet/service_account.rb +27 -0
  14. data/lib/objects/driver/gcp/authentication/utilities.rb +42 -0
  15. data/lib/objects/encryptor.rb +83 -0
  16. data/lib/objects/exception/base.rb +10 -3
  17. data/lib/objects/exception/ec2_driver_missing_authorization_keys.rb +11 -0
  18. data/lib/objects/exception/empty_namespace_tree.rb +11 -0
  19. data/lib/objects/exception/gcp_auth_service_account_missing_credentials.rb +11 -0
  20. data/lib/objects/exception/invalid_metadata_encryption_key.rb +1 -1
  21. data/lib/objects/exception/missing_gcp_service_account_credentials_filename.rb +11 -0
  22. data/lib/objects/exception/user_orchestration_error.rb +11 -0
  23. data/lib/objects/initialization/factory.rb +36 -0
  24. data/lib/objects/initialization/structure.rb +18 -0
  25. data/lib/objects/initialization/utils.rb +20 -0
  26. data/lib/objects/loading_bar/handler.rb +1 -1
  27. data/lib/objects/loading_bar/indicator/base.rb +1 -0
  28. data/lib/objects/modules/draw.rb +49 -0
  29. data/lib/objects/modules/tree.rb +157 -0
  30. data/lib/objects/modules/workspace_commands.rb +2 -32
  31. data/lib/objects/modules/workspace_menu.rb +113 -48
  32. data/lib/objects/node/attributes.rb +6 -0
  33. data/lib/objects/node/base.rb +27 -7
  34. data/lib/objects/node/cache_handler.rb +1 -1
  35. data/lib/objects/node/factory.rb +15 -11
  36. data/lib/objects/node/inventory/base.rb +9 -3
  37. data/lib/objects/node/inventory/defined.rb +18 -15
  38. data/lib/objects/node/inventory/merge.rb +9 -1
  39. data/lib/objects/node/inventory/subselect.rb +6 -4
  40. data/lib/objects/node/meta_data_factory.rb +1 -1
  41. data/lib/objects/node/meta_data_loader.rb +2 -2
  42. data/lib/objects/node/resources/inventory.rb +19 -0
  43. data/lib/objects/node/resources/merged.rb +23 -14
  44. data/lib/objects/node/resources/sub_inventory.rb +6 -5
  45. data/lib/objects/node/server/base.rb +35 -22
  46. data/lib/objects/node/server/dynamic/ec2.rb +0 -1
  47. data/lib/objects/node/server/dynamic/gcp.rb +0 -1
  48. data/lib/objects/node/server/static.rb +22 -9
  49. data/lib/objects/orchestration/base.rb +7 -1
  50. data/lib/objects/orchestration/interactive_terraform.rb +10 -16
  51. data/lib/objects/registry/command/external.rb +6 -2
  52. data/lib/objects/registry/command/group.rb +5 -1
  53. data/lib/objects/registry/loader.rb +3 -0
  54. data/lib/objects/ssh/command.rb +4 -8
  55. data/lib/objects/ssh/command_exec.rb +3 -1
  56. data/lib/objects/ssh/connection_wrangler.rb +34 -17
  57. data/lib/objects/ssh/connector.rb +17 -9
  58. data/lib/objects/ssh/driver.rb +7 -18
  59. data/lib/objects/ssh/driver_concerns/connection.rb +3 -11
  60. data/lib/objects/ssh/driver_concerns/functions.rb +7 -7
  61. data/lib/objects/ssh/proxy_chain.rb +19 -0
  62. data/lib/objects/ssh/proxy_chain_link.rb +26 -0
  63. data/lib/objects/ssh/proxy_hop.rb +47 -18
  64. data/lib/objects/ssh/script_exec.rb +9 -11
  65. data/lib/objects/startup.rb +7 -1
  66. data/lib/objects/terraform/output.rb +5 -1
  67. data/lib/objects/workspace.rb +10 -0
  68. data/patches/irb.rb +35 -1
  69. data/patches/string.rb +13 -0
  70. metadata +71 -25
  71. data/lib/objects/driver/static.rb +0 -6
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome::Driver::Gcp::Authentication
4
+ class SignetServiceAccountClient < Signet::OAuth2::Client
5
+ def initialize(scopes, service_account_json_path)
6
+ @scopes = scopes
7
+ @service_account_json_path = service_account_json_path
8
+ raise ::Bcome::Exception::GcpAuthServiceAccountMissingCredentials, @service_account_json_path unless File.exist?(@service_account_json_path)
9
+ end
10
+
11
+ def fetch_access_token(_options = {})
12
+ token = authorizer.fetch_access_token!
13
+ token
14
+ end
15
+
16
+ def authorize
17
+ @token ||= fetch_access_token
18
+ end
19
+
20
+ def authorizer
21
+ authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
22
+ json_key_io: File.open(@service_account_json_path),
23
+ scope: @scopes
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ module Bcome::Driver::Gcp::Authentication::Utilities
2
+
3
+ def oauth_redirect_html
4
+ ## [GR] Style rules: Explicitly no assets to be pulled from bcome remote (no tracking). Inline styles only.
5
+ ## Made an exception for the google font, as the user is already oauthing against google in any case.
6
+ <<-HTML
7
+ <html>
8
+ <head>
9
+ <script>
10
+ function closeWindow() {
11
+ window.open('', '_self', '');
12
+ window.close();
13
+ }
14
+ setTimeout(closeWindow, 10);
15
+ </script>
16
+ </head>
17
+ <style>
18
+ @import url("https://fonts.googleapis.com/css2?family=Catamaran:wght@200;500&display=swap");
19
+
20
+ body {
21
+ font-family: 'Catamaran', sans-serif;
22
+ font-weight: 200;
23
+ color: #3E4E60;
24
+ }
25
+ </style>
26
+ <body>#{oauth_redirect_body}</body>
27
+ </html>
28
+ HTML
29
+ end
30
+
31
+ def oauth_redirect_body
32
+ <<-HTML
33
+ <p>
34
+ OAuth redirection for namespace <strong>#{@node.keyed_namespace}</strong> complete.
35
+ </p>
36
+ <p>
37
+ You may close this window and return to the Bcome Console.
38
+ </p>
39
+ HTML
40
+ end
41
+
42
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'diffy'
4
+
3
5
  module Bcome
4
6
  class Encryptor
5
7
  UNENC_SIGNIFIER = ''
6
8
  ENC_SIGNIFIER = 'enc'
9
+ AFFIRMATIVE = 'yes'
7
10
 
8
11
  include Singleton
9
12
 
@@ -27,7 +30,16 @@ module Bcome
27
30
  puts "\n"
28
31
  print 'Please enter an encryption key (and if your data is already encrypted, you must provide the same key): '.informational
29
32
  @key = STDIN.noecho(&:gets).chomp
33
+ # puts "\n"
34
+ end
35
+
36
+ def prompt_to_overwrite
37
+ valid_answers = [AFFIRMATIVE, 'no']
30
38
  puts "\n"
39
+ print "Do you want to continue with unpacking this file? Your local changes would be overwritten [#{valid_answers.join(',')}]\s"
40
+ answer = STDIN.gets.chomp
41
+ prompt_to_overwrite unless valid_answers.include?(answer)
42
+ answer
31
43
  end
32
44
 
33
45
  def has_encrypted_files?
@@ -52,6 +64,64 @@ module Bcome
52
64
  nil
53
65
  end
54
66
 
67
+ def decrypt_file_data(filename)
68
+ raw_contents = File.read(filename)
69
+ raw_contents.send(:decrypt, @key)
70
+ end
71
+
72
+ def enc_file_diff(filename)
73
+ # Get decrypted file data
74
+ decrypted_data_for_filename = decrypt_file_data(filename)
75
+
76
+ # Get unpacked file data
77
+ opposing_filename = opposing_file_for_filename(filename)
78
+ return nil unless File.exist?(opposing_filename)
79
+
80
+ unpacked_file_data = File.read(opposing_filename)
81
+
82
+ # there are no differences
83
+ return nil if decrypted_data_for_filename.eql?(unpacked_file_data)
84
+
85
+ get_diffs(unpacked_file_data, decrypted_data_for_filename)
86
+ end
87
+
88
+ def opposing_file_for_filename(filename)
89
+ filename =~ %r{#{path_to_metadata}/(.+)\.enc}
90
+ "#{path_to_metadata}/#{Regexp.last_match(1)}"
91
+ end
92
+
93
+ def get_diffs(file_one, file_two)
94
+ diffy = ::Diffy::SplitDiff.new(file_one, file_two)
95
+ left_diffs = diffy.left.split("\n").each_with_index.collect { |l, index| "#{index + 1}:\s#{l}" }
96
+ right_diffs = diffy.right.split("\n").each_with_index.collect { |l, index| "#{index + 1}:\s#{l}" }
97
+
98
+ diffed_lines = (left_diffs + right_diffs).select { |line| line =~ /^[0-9]+:\s[+-](.+)$/ }
99
+ return nil if diffed_lines.empty?
100
+
101
+ diffed_lines.collect do |line|
102
+ line =~ /^[0-9]+:\s\+(.+)$/ ? line.bc_green : line.bc_red
103
+ end.join("\n")
104
+ end
105
+
106
+ def diff
107
+ prompt_for_key
108
+ puts "\n"
109
+ all_encrypted_filenames.each do |filename|
110
+ opposing_file = opposing_file_for_filename(filename)
111
+ if File.exist?(opposing_file)
112
+ if diffs = enc_file_diff(filename)
113
+ puts "\n[+/-]\s".warning + filename + "\sis different to your local unpacked version\n\n"
114
+ puts diffs + "\n\n"
115
+ else
116
+ puts filename.to_s.informational + "\s- no differences".bc_green
117
+ end
118
+ else
119
+ puts filename.to_s.informational + "\s- new file".warning
120
+ end
121
+ end
122
+ puts "\n"
123
+ end
124
+
55
125
  def toggle_packed_files(filenames, packer_method)
56
126
  raise 'Missing encryption key. Please set an encryption key' unless @key
57
127
 
@@ -63,6 +133,18 @@ module Bcome
63
133
  filename =~ %r{#{path_to_metadata}/(.+)\.enc}
64
134
  opposing_filename = Regexp.last_match(1)
65
135
  action = 'Unpacking'
136
+
137
+ # Skip unpacking a file if there are local modifications that the user does not want to lose.
138
+ if diffs = enc_file_diff(filename)
139
+ puts "\n[+/-]\s".warning + filename + "\sis different to your local unpacked version\n\n"
140
+ puts diffs
141
+
142
+ if prompt_to_overwrite != AFFIRMATIVE
143
+ puts "\n\nskipping\s".warning + filename + "\n"
144
+ next
145
+ end
146
+ puts "\n"
147
+ end
66
148
  else
67
149
  filename =~ %r{#{path_to_metadata}/(.*)}
68
150
  opposing_filename = "#{Regexp.last_match(1)}.enc"
@@ -71,6 +153,7 @@ module Bcome
71
153
 
72
154
  # Write encrypted/decryption action
73
155
  enc_decrypt_result = raw_contents.send(packer_method, @key)
156
+ print "\n\n"
74
157
  puts "#{action}\s".informational + filename + "\sto\s".informational + "#{path_to_metadata}/" + opposing_filename
75
158
  write_file(opposing_filename, enc_decrypt_result)
76
159
  end
@@ -8,12 +8,19 @@ module Bcome
8
8
  end
9
9
 
10
10
  def message
11
- "#{message_prefix}#{@message_suffix ? + (!message_prefix.empty? ? ':' : '').to_s + " #{@message_suffix}" : ''}"
11
+ "#{message_prefix}#{if @message_suffix
12
+ + (!message_prefix.empty? ? ':' : '').to_s + " #{@message_suffix}"
13
+ else
14
+ ''
15
+ end}"
12
16
  end
13
17
 
14
- def pretty_display
15
- puts "\n\n#{message}\n".error
18
+ def pretty_display(show_backtrace = false)
19
+ puts "\n" + message.error
20
+ print backtrace.join("\n") if show_backtrace
21
+ print "\n"
16
22
  end
23
+
17
24
  end
18
25
  end
19
26
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Exception
5
+ class Ec2DriverMissingAuthorizationKeys < ::Bcome::Exception::Base
6
+ def message_prefix
7
+ 'Missing authorization keys for AWS. Expected to find keys at '
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Exception
5
+ class EmptyNamespaceTree < ::Bcome::Exception::Base
6
+ def message_prefix
7
+ 'Empty namespace tree'
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Exception
5
+ class GcpAuthServiceAccountMissingCredentials < ::Bcome::Exception::Base
6
+ def message_prefix
7
+ 'Expected GCP service account credentials at'
8
+ end
9
+ end
10
+ end
11
+ end
@@ -4,7 +4,7 @@ module Bcome
4
4
  module Exception
5
5
  class InvalidMetaDataEncryptionKey < ::Bcome::Exception::Base
6
6
  def message_prefix
7
- 'Your metadata encryption key is invalid - your metadata files are encrypted with a different key.'
7
+ 'Your metadata encryption key is invalid.'
8
8
  end
9
9
  end
10
10
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Exception
5
+ class MissingGcpServiceAccountCredentialsFilename < ::Bcome::Exception::Base
6
+ def message_prefix
7
+ "Cannot authenticate with GCP - missing service account credentials file name in networks.yml. Define this with a 'service_account_credentials' key"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Exception
5
+ class UserOrchestrationError < ::Bcome::Exception::Base
6
+ def message_prefix
7
+ "Exception caught in orchestration script"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,36 @@
1
+ require 'fileutils'
2
+
3
+ module Bcome::Initialization
4
+ class Factory
5
+ include ::Bcome::Initialization::Utils
6
+ include ::Bcome::Initialization::Structure
7
+
8
+ class << self
9
+ def do
10
+ new.do
11
+ end
12
+ end
13
+
14
+ def initialize
15
+ @created = []
16
+ @exists = []
17
+ end
18
+
19
+ def do
20
+ puts "\nInitialising Bcome".title.bold
21
+ initialization_paths.each do |conf|
22
+ create_file_utils(conf[:method], conf[:paths])
23
+ end
24
+ summarize(@created, "\nThe following paths were created")
25
+ summarize(@exists, "\nThe following paths exist already, and were untouched")
26
+ puts "\n"
27
+ end
28
+
29
+ def summarize(paths, caption)
30
+ return unless paths.any?
31
+
32
+ puts "#{caption}:".informational
33
+ paths.each { |path| puts path.resource_key }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,18 @@
1
+ module Bcome::Initialization::Structure
2
+ def initialization_paths
3
+ [
4
+ { # Configuration directories
5
+ paths: ['bcome', 'bcome/metadata', 'bcome/orchestration'],
6
+ method: :create_as_directory
7
+ },
8
+ { # Configuration files
9
+ paths: ['bcome/networks.yml', 'bcome/registry.yml'],
10
+ method: :initialize_empty_yaml_config
11
+ },
12
+ { # Cloud provider authorisation directories
13
+ paths: ['.gauth', '.aws'],
14
+ method: :create_as_directory
15
+ }
16
+ ]
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ module Bcome::Initialization::Utils
2
+ def initialize_empty_yaml_config(path)
3
+ File.write(path, {}.to_yaml)
4
+ end
5
+
6
+ def create_as_directory(path)
7
+ ::FileUtils.mkdir_p(path)
8
+ end
9
+
10
+ def create_file_utils(method, paths)
11
+ paths.each do |path|
12
+ if path.is_file_or_directory?
13
+ @exists << path
14
+ else
15
+ send(method, path)
16
+ @created << path
17
+ end
18
+ end
19
+ end
20
+ end
@@ -72,7 +72,7 @@ module Bcome
72
72
 
73
73
  def signal_failure
74
74
  do_signal(::Bcome::LoadingBar::Indicator::Base::SIGNAL_FAILURE)
75
- # Keeo parent indicator in sync (see #signal_stop)
75
+ # Keep parent indicator in sync (see #signal_stop)
76
76
  @indicator.increment_failure
77
77
  end
78
78
  end
@@ -24,6 +24,7 @@ module Bcome
24
24
  print "\n"
25
25
  loop do
26
26
  increment
27
+ sleep 0.1
27
28
  show
28
29
  end
29
30
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Draw
5
+ # see: https://en.wikipedia.org/wiki/Box-drawing_character
6
+
7
+ ## Tree shapes
8
+ BOTTOM_ANCHOR = '└───╸'
9
+ MID_SHIPS = '├───╸'
10
+ BRANCH = '│'
11
+ LEFT_PADDING = "\s" * 6
12
+ INGRESS = '│'
13
+ BLIP = '▐▆'
14
+
15
+ # # Box shapes
16
+ BOX_SIDE = '│'
17
+ BOX_TOP_LEFT = '┌'
18
+ BOX_TOP_RIGHT = '┐'
19
+ BOX_BOTTOM_LEFT = '└'
20
+ BOX_BOTTOM_RIGHT = '┘'
21
+ BOX_HORIZONTAL_LINE = '─'
22
+
23
+ # Takes an array of strings, each representing a line
24
+ # Draws a box around the lines, and returns a new array
25
+ # padding may be provided
26
+ def box_it(array_of_lines, padding = 1, _box_colour = :bc_cyan)
27
+ max_length = max_box_line_length(array_of_lines)
28
+ pad_string = "\s" * padding
29
+
30
+ box_lines = [
31
+ # Set the top box line
32
+ "#{BOX_TOP_LEFT}#{BOX_HORIZONTAL_LINE * (max_length + (padding + 1))}#{BOX_TOP_RIGHT}"
33
+ ]
34
+
35
+ array_of_lines.each do |line|
36
+ line_length = line.sanitize.length
37
+ box_lines << "#{BOX_SIDE}#{pad_string}" + line.to_s + "#{"\s" * (max_length - line_length)}#{pad_string}#{BOX_SIDE}"
38
+ end
39
+
40
+ # Set the bottom box line
41
+ box_lines << "#{BOX_BOTTOM_LEFT}#{BOX_HORIZONTAL_LINE * (max_length + (padding + 1))}#{BOX_BOTTOM_RIGHT}"
42
+ box_lines
43
+ end
44
+
45
+ def max_box_line_length(array_of_lines)
46
+ array_of_lines.max_by { |string| string.sanitize.length }.sanitize.length
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bcome
4
+ module Tree
5
+ include Bcome::Draw
6
+
7
+ def tree
8
+ title_prefix = 'Namespace tree'
9
+ build_tree(:network_namespace_tree_data, title_prefix)
10
+ end
11
+
12
+ def routes
13
+ if machines.empty?
14
+ puts "\nNo routes are found below this namespace (empty server list)\n".warning
15
+ else
16
+ title_prefix = 'Ssh connection routes'
17
+ build_tree(:routing_tree_data, title_prefix)
18
+ end
19
+ end
20
+
21
+ def routing_tree_data
22
+ @tree = {}
23
+
24
+ # For each namespace, we have many proxy chains
25
+ proxy_chain_link.link.each do |proxy_chain, machines|
26
+ is_direct = proxy_chain.hops.any? ? false : true
27
+
28
+ if inventory?
29
+ load_nodes unless nodes_loaded?
30
+ end
31
+
32
+ ## Machine data
33
+ machine_data = {}
34
+ machines.each do |machine|
35
+ key = machine.routing_tree_line(is_direct)
36
+ machine_data[key] = nil
37
+ end
38
+
39
+ ## Construct Hop data
40
+ hops = proxy_chain.hops
41
+ hop_lines = hops.compact.enum_for(:each_with_index).collect { |hop, index| hop.pretty_proxy_details(index + 1) }
42
+
43
+ @tree.merge!(to_nested_hash(hop_lines, machine_data))
44
+ end
45
+
46
+ @tree
47
+ end
48
+
49
+ def to_nested_hash(array, data)
50
+ nested = array.reverse.inject(data) { |a, n| { n => a } }
51
+ nested.is_a?(String) ? { "#{nested}": nil } : nested
52
+ end
53
+
54
+ def network_namespace_tree_data
55
+ @tree = {}
56
+
57
+ resources.sort_by(&:identifier).each do |resource|
58
+ next if resource.hide?
59
+
60
+ if resource.inventory?
61
+ resource.load_nodes unless resource.nodes_loaded?
62
+ end
63
+
64
+ unless resource.is_a?(Bcome::Node::Inventory::Merge)
65
+ next if resource.parent && !resource.parent.resources.is_active_resource?(resource)
66
+ end
67
+ @tree[resource.namespace_tree_line] = resource.resources.any? ? resource.network_namespace_tree_data : nil
68
+ end
69
+
70
+ @tree
71
+ end
72
+
73
+ def namespace_tree_line
74
+ return "#{type.bc_green} #{identifier} (empty set)" if !server? && !resources.has_active_nodes?
75
+
76
+ "#{type.bc_green} #{identifier}"
77
+ end
78
+
79
+ def routing_tree_line(is_direct = true)
80
+ address = if is_direct && public_ip_address
81
+ public_ip_address
82
+ else
83
+ internal_ip_address
84
+ end
85
+
86
+ [
87
+ type.to_s.bc_cyan,
88
+ "namespace:\s".bc_green + keyed_namespace,
89
+ "ip address\s".bc_green + address.to_s,
90
+ "user\s".bc_green + ssh_driver.user
91
+ ]
92
+ end
93
+
94
+ def build_tree(data_build_method, title_prefix)
95
+ data = send(data_build_method)
96
+
97
+ @lines = []
98
+ title = "#{title_prefix.informational}\s#{namespace}"
99
+ @lines << "\n"
100
+ @lines << "#{BLIP}\s\s\s#{title}"
101
+ @lines << INGRESS.to_s
102
+
103
+ if data.nil?
104
+ parent.build_tree(data_build_method)
105
+ return
106
+ end
107
+
108
+ recurse_tree_lines(data)
109
+
110
+ @lines.each do |line|
111
+ print "#{LEFT_PADDING}#{line}\n"
112
+ end
113
+
114
+ print "\n\n"
115
+ p
116
+ end
117
+
118
+ def recurse_tree_lines(data, padding = '')
119
+ # @lines << padding + BRANCH
120
+
121
+ data.each_with_index do |config, index|
122
+ key = config[0]
123
+ values = config[1]
124
+
125
+ anchor, branch = deduce_tree_structure(index, data.size)
126
+
127
+ labels = key.is_a?(Array) ? key : [key]
128
+
129
+ labels.each_with_index do |label, index|
130
+ key_string = if index == 0 #  First line
131
+ "#{anchor}\s#{label}"
132
+ else # Any subsequent line
133
+ "#{branch}#{"\s" * 4}\s#{label}"
134
+ end
135
+
136
+ entry_string = "#{padding}#{key_string}"
137
+ @lines << entry_string
138
+ end # End labels group
139
+
140
+ @lines << "#{padding}#{branch}" if labels.size > 1
141
+
142
+ next unless values&.is_a?(Hash)
143
+
144
+ tab_padding = padding + branch + ("\s" * (anchor.length + 4))
145
+ recurse_tree_lines(values, tab_padding)
146
+ @lines << padding + branch
147
+ end
148
+ nil
149
+ end
150
+
151
+ def deduce_tree_structure(index, number_lines)
152
+ return BOTTOM_ANCHOR, "\s" if (index + 1) == number_lines
153
+
154
+ [MID_SHIPS, BRANCH]
155
+ end
156
+ end
157
+ end