bcome 1.4.0 → 2.0.0

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.
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