tugboat 2.2.2 → 2.2.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +520 -0
  3. data/CHANGELOG.md +9 -0
  4. data/Gemfile +0 -6
  5. data/README.md +34 -2
  6. data/Rakefile +6 -1
  7. data/bin/tugboat +2 -2
  8. data/features/cassettes/config/Array_of_SSH_Keys_in_Config.yml +1 -1
  9. data/features/cassettes/config/Single_SSH_key_as_number_in_config.yml +1 -1
  10. data/features/step_definitions/steps.rb +1 -1
  11. data/features/support/env.rb +7 -3
  12. data/lib/tugboat.rb +2 -2
  13. data/lib/tugboat/cli.rb +394 -444
  14. data/lib/tugboat/config.rb +43 -61
  15. data/lib/tugboat/middleware.rb +33 -33
  16. data/lib/tugboat/middleware/add_key.rb +12 -13
  17. data/lib/tugboat/middleware/ask_for_credentials.rb +12 -13
  18. data/lib/tugboat/middleware/base.rb +25 -20
  19. data/lib/tugboat/middleware/check_configuration.rb +3 -6
  20. data/lib/tugboat/middleware/check_credentials.rb +0 -1
  21. data/lib/tugboat/middleware/check_droplet_active.rb +2 -4
  22. data/lib/tugboat/middleware/check_droplet_inactive.rb +2 -4
  23. data/lib/tugboat/middleware/config.rb +3 -5
  24. data/lib/tugboat/middleware/confirm_action.rb +4 -6
  25. data/lib/tugboat/middleware/create_droplet.rb +27 -44
  26. data/lib/tugboat/middleware/custom_logger.rb +52 -54
  27. data/lib/tugboat/middleware/destroy_droplet.rb +5 -6
  28. data/lib/tugboat/middleware/destroy_image.rb +5 -6
  29. data/lib/tugboat/middleware/find_droplet.rb +43 -47
  30. data/lib/tugboat/middleware/find_image.rb +23 -29
  31. data/lib/tugboat/middleware/halt_droplet.rb +9 -10
  32. data/lib/tugboat/middleware/info_droplet.rb +30 -33
  33. data/lib/tugboat/middleware/info_image.rb +1 -1
  34. data/lib/tugboat/middleware/inject_client.rb +8 -10
  35. data/lib/tugboat/middleware/inject_configuration.rb +1 -2
  36. data/lib/tugboat/middleware/list_droplets.rb +9 -10
  37. data/lib/tugboat/middleware/list_images.rb +9 -9
  38. data/lib/tugboat/middleware/list_regions.rb +1 -1
  39. data/lib/tugboat/middleware/list_sizes.rb +1 -1
  40. data/lib/tugboat/middleware/list_ssh_keys.rb +1 -3
  41. data/lib/tugboat/middleware/password_reset.rb +6 -7
  42. data/lib/tugboat/middleware/rebuild_droplet.rb +7 -7
  43. data/lib/tugboat/middleware/resize_droplet.rb +6 -7
  44. data/lib/tugboat/middleware/restart_droplet.rb +4 -11
  45. data/lib/tugboat/middleware/snapshot_droplet.rb +7 -8
  46. data/lib/tugboat/middleware/ssh_droplet.rb +30 -31
  47. data/lib/tugboat/middleware/start_droplet.rb +5 -5
  48. data/lib/tugboat/middleware/wait_for_state.rb +2 -3
  49. data/lib/tugboat/version.rb +1 -1
  50. data/spec/cli/add_key_spec.rb +25 -28
  51. data/spec/cli/authorize_cli_spec.rb +57 -60
  52. data/spec/cli/config_cli_spec.rb +8 -11
  53. data/spec/cli/create_cli_spec.rb +40 -46
  54. data/spec/cli/debug_cli_spec.rb +29 -29
  55. data/spec/cli/destroy_cli_spec.rb +58 -60
  56. data/spec/cli/destroy_image_cli_spec.rb +42 -45
  57. data/spec/cli/droplets_cli_spec.rb +62 -64
  58. data/spec/cli/env_variable_spec.rb +14 -15
  59. data/spec/cli/halt_cli_spec.rb +65 -69
  60. data/spec/cli/help_cli_spec.rb +8 -8
  61. data/spec/cli/images_cli_spec.rb +28 -30
  62. data/spec/cli/info_cli_spec.rb +144 -147
  63. data/spec/cli/info_image_cli_spec.rb +57 -60
  64. data/spec/cli/keys_cli_spec.rb +8 -10
  65. data/spec/cli/password_reset_cli_spec.rb +56 -56
  66. data/spec/cli/rebuild_cli_spec.rb +194 -198
  67. data/spec/cli/regions_cli_spec.rb +8 -8
  68. data/spec/cli/resize_cli_spec.rb +54 -56
  69. data/spec/cli/restart_cli_spec.rb +53 -57
  70. data/spec/cli/sizes_cli_spec.rb +7 -8
  71. data/spec/cli/snapshot_cli_spec.rb +50 -53
  72. data/spec/cli/ssh_cli_spec.rb +41 -42
  73. data/spec/cli/start_cli_spec.rb +48 -52
  74. data/spec/cli/verify_cli_spec.rb +22 -25
  75. data/spec/cli/version_cli_spec.rb +6 -8
  76. data/spec/cli/wait_cli_spec.rb +50 -52
  77. data/spec/config_spec.rb +56 -57
  78. data/spec/middleware/base_spec.rb +5 -6
  79. data/spec/middleware/check_configuration_spec.rb +5 -7
  80. data/spec/middleware/check_credentials_spec.rb +9 -10
  81. data/spec/middleware/check_droplet_active_spec.rb +5 -7
  82. data/spec/middleware/check_droplet_inactive_spec.rb +5 -7
  83. data/spec/middleware/find_droplet_spec.rb +4 -5
  84. data/spec/middleware/find_image_spec.rb +4 -5
  85. data/spec/middleware/inject_client_spec.rb +9 -12
  86. data/spec/middleware/inject_configuration_spec.rb +4 -7
  87. data/spec/middleware/ssh_droplet_spec.rb +70 -73
  88. data/spec/shared/environment.rb +18 -20
  89. data/spec/spec_helper.rb +4 -4
  90. data/tugboat.gemspec +10 -6
  91. metadata +88 -17
@@ -11,7 +11,7 @@ module Tugboat
11
11
  # First, if nothing is provided to us, we should quit and
12
12
  # let the user know.
13
13
  if !user_fuzzy_name && !user_image_name && !user_image_id
14
- say "Tugboat attempted to find an image with no arguments.", :red
14
+ say 'Tugboat attempted to find an image with no arguments.', :red
15
15
  say "Try running `tugboat #{env['tugboat_action']} imagename`", :green
16
16
  say "For more help run: `tugboat help #{env['tugboat_action']}`", :blue
17
17
  exit 1
@@ -24,7 +24,7 @@ module Tugboat
24
24
 
25
25
  # Easy for us if they provide an id. Just set it to the image_id
26
26
  if user_image_id
27
- say "Image id provided. Finding Image...", nil, false
27
+ say 'Image id provided. Finding Image...', nil, false
28
28
  response = ocean.image.show user_image_id
29
29
 
30
30
  unless response.success?
@@ -32,26 +32,26 @@ module Tugboat
32
32
  exit 1
33
33
  end
34
34
 
35
- env["image_id"] = response.image.id
36
- env["image_name"] = "(#{response.image.name})"
35
+ env['image_id'] = response.image.id
36
+ env['image_name'] = "(#{response.image.name})"
37
37
  end
38
38
 
39
39
  # If they provide a name, we need to get the ID for it.
40
40
  # This requires a lookup.
41
- if user_image_name && !env["image_id"]
42
- say "Image name provided. Finding Image...", nil, false
41
+ if user_image_name && !env['image_id']
42
+ say 'Image name provided. Finding Image...', nil, false
43
43
 
44
44
  # Look for the image by an exact name match.
45
45
  ocean.image.all['images'].each do |d|
46
46
  if d.name == user_image_name
47
- env["image_id"] = d.id
48
- env["image_name"] = "(#{d.name})"
47
+ env['image_id'] = d.id
48
+ env['image_name'] = "(#{d.name})"
49
49
  end
50
50
  end
51
51
 
52
52
  # If we coulnd't find it, tell the user and drop out of the
53
53
  # sequence.
54
- if !env["image_id"]
54
+ unless env['image_id']
55
55
  say "error\nUnable to find an image named '#{user_image_name}'.", :red
56
56
  exit 1
57
57
  end
@@ -62,61 +62,55 @@ module Tugboat
62
62
  # with a flag.
63
63
  #
64
64
  # This requires a lookup.
65
- if user_fuzzy_name && !env["image_id"]
66
- say "Image fuzzy name provided. Finding image ID...", nil, false
65
+ if user_fuzzy_name && !env['image_id']
66
+ say 'Image fuzzy name provided. Finding image ID...', nil, false
67
67
 
68
68
  found_images = []
69
69
  choices = []
70
70
 
71
- ocean.image.all['images'].each_with_index do |d, i|
72
-
71
+ ocean.image.all['images'].each_with_index do |d, _i|
73
72
  # Check to see if one of the image names have the fuzzy string.
74
- if d.name.upcase.include? user_fuzzy_name.upcase
75
- found_images << d
76
- end
73
+ found_images << d if d.name.upcase.include? user_fuzzy_name.upcase
77
74
 
78
75
  unless d.slug.nil?
79
- if d.slug.upcase.include? user_fuzzy_name.upcase
80
- found_images << d
81
- end
76
+ found_images << d if d.slug.upcase.include? user_fuzzy_name.upcase
82
77
  end
83
78
  end
84
79
 
85
80
  # Check to see if we have more then one image, and prompt
86
81
  # a user to choose otherwise.
87
82
  if found_images.length == 1
88
- env["image_id"] = found_images.first.id
89
- env["image_name"] = "(#{found_images.first.name})"
83
+ env['image_id'] = found_images.first.id
84
+ env['image_name'] = "(#{found_images.first.name})"
90
85
  elsif found_images.length > 1
91
86
  # Did we run the multiple questionairre?
92
87
  did_run_multiple = true
93
88
 
94
- say "Multiple images found."
89
+ say 'Multiple images found.'
95
90
  say
96
91
  found_images.each_with_index do |d, i|
97
92
  say "#{i}) #{d.name} (#{d.id})"
98
93
  choices << i.to_s
99
94
  end
100
95
  say
101
- choice = ask "Please choose a image:", :limited_to => choices
102
- env["image_id"] = found_images[choice.to_i].id
103
- env["image_name"] = found_images[choice.to_i].name
96
+ choice = ask 'Please choose a image:', limited_to: choices
97
+ env['image_id'] = found_images[choice.to_i].id
98
+ env['image_name'] = found_images[choice.to_i].name
104
99
  end
105
100
 
106
101
  # If we coulnd't find it, tell the user and drop out of the
107
102
  # sequence.
108
- if !env["image_id"]
103
+ unless env['image_id']
109
104
  say "error\nUnable to find an image named '#{user_fuzzy_name}'.", :red
110
105
  exit 1
111
106
  end
112
107
  end
113
108
 
114
- if !did_run_multiple
115
- say "done#{CLEAR}, #{env["image_id"]} #{env["image_name"]}", :green
109
+ unless did_run_multiple
110
+ say "done#{CLEAR}, #{env['image_id']} #{env['image_name']}", :green
116
111
  end
117
112
  @app.call(env)
118
113
  end
119
114
  end
120
115
  end
121
116
  end
122
-
@@ -4,19 +4,19 @@ module Tugboat
4
4
  def call(env)
5
5
  ocean = env['barge']
6
6
 
7
- response = if env["user_droplet_hard"]
8
- say "Queuing hard shutdown for #{env["droplet_id"]} #{env["droplet_name"]}...", nil, false
9
- ocean.droplet.power_off env["droplet_id"]
10
- else
11
- say "Queuing shutdown for #{env["droplet_id"]} #{env["droplet_name"]}...", nil, false
12
- ocean.droplet.shutdown env["droplet_id"]
7
+ response = if env['user_droplet_hard']
8
+ say "Queuing hard shutdown for #{env['droplet_id']} #{env['droplet_name']}...", nil, false
9
+ ocean.droplet.power_off env['droplet_id']
10
+ else
11
+ say "Queuing shutdown for #{env['droplet_id']} #{env['droplet_name']}...", nil, false
12
+ ocean.droplet.shutdown env['droplet_id']
13
13
  end
14
14
 
15
- unless response.success?
15
+ if response.success?
16
+ say 'Halt successful!', :green
17
+ else
16
18
  say "Failed to halt on Droplet: #{response.message}", :red
17
19
  exit 1
18
- else
19
- say "Halt successful!", :green
20
20
  end
21
21
 
22
22
  @app.call(env)
@@ -24,4 +24,3 @@ module Tugboat
24
24
  end
25
25
  end
26
26
  end
27
-
@@ -4,7 +4,7 @@ module Tugboat
4
4
  def call(env)
5
5
  ocean = env['barge']
6
6
 
7
- response = ocean.droplet.show env["droplet_id"]
7
+ response = ocean.droplet.show env['droplet_id']
8
8
 
9
9
  check_response_success('get info for Droplet', response)
10
10
 
@@ -15,45 +15,45 @@ module Tugboat
15
15
  exit 1
16
16
  end
17
17
 
18
- if droplet.status == "active"
19
- status_color = GREEN
20
- else
21
- status_color = RED
22
- end
18
+ status_color = if droplet.status == 'active'
19
+ GREEN
20
+ else
21
+ RED
22
+ end
23
23
 
24
- attribute = env["user_attribute"]
24
+ attribute = env['user_attribute']
25
25
 
26
- droplet_ip4_public = droplet.networks.v4.detect { |address| address.type == 'public' }.ip_address
27
- droplet_ip6_public = droplet.networks.v6.detect { |address| address.type == 'public' }.ip_address unless droplet.networks.v6.empty?
28
- check_private_ip = droplet.networks.v4.detect { |address| address.type == 'private' }
26
+ droplet_ip4_public = droplet.networks.v4.find { |address| address.type == 'public' }.ip_address
27
+ droplet_ip6_public = droplet.networks.v6.find { |address| address.type == 'public' }.ip_address unless droplet.networks.v6.empty?
28
+ check_private_ip = droplet.networks.v4.find { |address| address.type == 'private' }
29
29
  droplet_private_ip = check_private_ip.ip_address if check_private_ip
30
30
 
31
31
  attributes_list = [
32
- ["name", droplet.name],
33
- ["id", droplet.id],
34
- ["status", droplet.status],
35
- ["ip4", droplet_ip4_public],
36
- ["ip6", droplet_ip6_public],
37
- ["private_ip", droplet_private_ip],
38
- ["region", droplet.region.slug],
39
- ["image", droplet.image.id],
40
- ["size", droplet.size_slug],
41
- ["backups_active", !droplet.backup_ids.empty?]
32
+ ['name', droplet.name],
33
+ ['id', droplet.id],
34
+ ['status', droplet.status],
35
+ ['ip4', droplet_ip4_public],
36
+ ['ip6', droplet_ip6_public],
37
+ ['private_ip', droplet_private_ip],
38
+ ['region', droplet.region.slug],
39
+ ['image', droplet.image.id],
40
+ ['size', droplet.size_slug],
41
+ ['backups_active', !droplet.backup_ids.empty?]
42
42
  ]
43
43
  attributes = Hash[*attributes_list.flatten(1)]
44
44
 
45
45
  if attribute
46
- if attributes.has_key? attribute
46
+ if attributes.key? attribute
47
47
  say attributes[attribute]
48
48
  else
49
49
  say "Invalid attribute \"#{attribute}\"", :red
50
- say "Provide one of the following:", :red
50
+ say 'Provide one of the following:', :red
51
51
  attributes_list.each { |a| say " #{a[0]}", :red }
52
52
  exit 1
53
53
  end
54
54
  else
55
- if env["user_porcelain"]
56
- attributes_list.select{ |a| a[1] != nil }.each{ |a| say "#{a[0]} #{a[1]}"}
55
+ if env['user_porcelain']
56
+ attributes_list.select { |a| !a[1].nil? }.each { |a| say "#{a[0]} #{a[1]}" }
57
57
  else
58
58
  say
59
59
  say "Name: #{droplet.name}"
@@ -62,15 +62,13 @@ module Tugboat
62
62
  say "IP4: #{droplet_ip4_public}"
63
63
  say "IP6: #{droplet_ip6_public}" unless droplet.networks.v6.empty?
64
64
 
65
- if droplet_private_ip
66
- say "Private IP: #{droplet_private_ip}"
67
- end
65
+ say "Private IP: #{droplet_private_ip}" if droplet_private_ip
68
66
 
69
- if droplet.image.slug.nil?
70
- image_description = droplet.image.name
71
- else
72
- image_description = droplet.image.slug
73
- end
67
+ image_description = if droplet.image.slug.nil?
68
+ droplet.image.name
69
+ else
70
+ droplet.image.slug
71
+ end
74
72
 
75
73
  say "Region: #{droplet.region.name} - #{droplet.region.slug}"
76
74
  say "Image: #{droplet.image.id} - #{image_description}"
@@ -84,4 +82,3 @@ module Tugboat
84
82
  end
85
83
  end
86
84
  end
87
-
@@ -4,7 +4,7 @@ module Tugboat
4
4
  def call(env)
5
5
  ocean = env['barge']
6
6
 
7
- response = ocean.image.show env["image_id"]
7
+ response = ocean.image.show env['image_id']
8
8
 
9
9
  unless response.success?
10
10
  say "Failed to get info for Image: #{response.message}", :red
@@ -5,19 +5,17 @@ module Tugboat
5
5
  module Middleware
6
6
  # Inject the digital ocean client into the environment
7
7
  class InjectClient < Base
8
+ def call(env)
9
+ # Sets the digital ocean client into the environment for use
10
+ # later.
11
+ @access_token = env['config'].access_token
8
12
 
9
- def call(env)
10
- # Sets the digital ocean client into the environment for use
11
- # later.
12
- @access_token = env["config"].access_token
13
+ env['barge'] = Barge::Client.new(access_token: @access_token)
13
14
 
14
- env['barge'] = Barge::Client.new(:access_token => @access_token)
15
+ env['barge'].faraday.use CustomLogger if ENV['DEBUG']
15
16
 
16
- env['barge'].faraday.use CustomLogger if ENV['DEBUG']
17
-
18
- @app.call(env)
19
- end
17
+ @app.call(env)
18
+ end
20
19
  end
21
20
  end
22
21
  end
23
-
@@ -5,11 +5,10 @@ module Tugboat
5
5
  def call(env)
6
6
  config = Tugboat::Configuration.instance
7
7
 
8
- env["config"] = config
8
+ env['config'] = config
9
9
 
10
10
  @app.call(env)
11
11
  end
12
12
  end
13
13
  end
14
14
  end
15
-
@@ -13,22 +13,22 @@ module Tugboat
13
13
  droplet_list.each do |droplet|
14
14
  has_one = true
15
15
 
16
- private_addr = droplet.networks.v4.detect { |address| address.type == 'private' }
16
+ private_addr = droplet.networks.v4.find { |address| address.type == 'private' }
17
17
  if private_addr
18
18
  private_ip = ", private_ip: #{private_addr.ip_address}"
19
19
  end
20
20
 
21
- if droplet.status == "active"
22
- status_color = GREEN
23
- else
24
- status_color = RED
25
- end
21
+ status_color = if droplet.status == 'active'
22
+ GREEN
23
+ else
24
+ RED
25
+ end
26
26
 
27
- public_addr = droplet.networks.v4.detect { |address| address.type == 'public' }
28
- say "#{droplet.name} (ip: #{public_addr.ip_address}#{private_ip}, status: #{status_color}#{droplet.status}#{CLEAR}, region: #{droplet.region.slug}, id: #{droplet.id}#{env["include_urls"] ? droplet_id_to_url(droplet.id) : '' })"
27
+ public_addr = droplet.networks.v4.find { |address| address.type == 'public' }
28
+ say "#{droplet.name} (ip: #{public_addr.ip_address}#{private_ip}, status: #{status_color}#{droplet.status}#{CLEAR}, region: #{droplet.region.slug}, id: #{droplet.id}#{env['include_urls'] ? droplet_id_to_url(droplet.id) : ''})"
29
29
  end
30
30
 
31
- if not has_one
31
+ unless has_one
32
32
  say "You don't appear to have any droplets.", :red
33
33
  say "Try creating one with #{GREEN}\`tugboat create\`#{CLEAR}"
34
34
  end
@@ -44,4 +44,3 @@ module Tugboat
44
44
  end
45
45
  end
46
46
  end
47
-
@@ -3,33 +3,33 @@ module Tugboat
3
3
  class ListImages < Base
4
4
  def call(env)
5
5
  ocean = env['barge']
6
- my_images = ocean.image.all(:private => true)
6
+ my_images = ocean.image.all(private: true)
7
7
  public_images = ocean.image.all.images - my_images.images
8
8
 
9
9
  if env['user_show_just_private_images']
10
- say "Showing just private images", :green
11
- say "Private Images:", :blue
10
+ say 'Showing just private images', :green
11
+ say 'Private Images:', :blue
12
12
  my_images_list = my_images.images
13
13
  if my_images_list.nil? || my_images_list.empty?
14
- say "No private images found"
14
+ say 'No private images found'
15
15
  else
16
16
  my_images_list.each do |image|
17
17
  say "#{image.name} (id: #{image.id}, distro: #{image.distribution})"
18
18
  end
19
19
  end
20
20
  else
21
- say "Showing both private and public images"
22
- say "Private Images:", :blue
21
+ say 'Showing both private and public images'
22
+ say 'Private Images:', :blue
23
23
  my_images_list = my_images.images
24
24
  if my_images_list.nil? || my_images_list.empty?
25
- say "No private images found"
25
+ say 'No private images found'
26
26
  else
27
27
  my_images_list.each do |image|
28
28
  say "#{image.name} (id: #{image.id}, distro: #{image.distribution})"
29
29
  end
30
30
  end
31
31
  say ''
32
- say "Public Images:", :blue
32
+ say 'Public Images:', :blue
33
33
  public_images.each do |image|
34
34
  say "#{image.name} (slug: #{image.slug}, id: #{image.id}, distro: #{image.distribution})"
35
35
  end
@@ -39,4 +39,4 @@ module Tugboat
39
39
  end
40
40
  end
41
41
  end
42
- end
42
+ end
@@ -5,7 +5,7 @@ module Tugboat
5
5
  ocean = env['barge']
6
6
  regions = ocean.region.all.regions.sort_by(&:name)
7
7
 
8
- say "Regions:"
8
+ say 'Regions:'
9
9
  regions.each do |region|
10
10
  say "#{region.name} (slug: #{region.slug})"
11
11
  end
@@ -5,7 +5,7 @@ module Tugboat
5
5
  ocean = env['barge']
6
6
  sizes = ocean.size.all.sizes
7
7
 
8
- say "Sizes:"
8
+ say 'Sizes:'
9
9
  sizes.each do |size|
10
10
  say "Disk: #{size.disk}GB, Memory: #{size.memory.round}MB (slug: #{size.slug})"
11
11
  end
@@ -2,11 +2,10 @@ module Tugboat
2
2
  module Middleware
3
3
  class ListSSHKeys < Base
4
4
  def call(env)
5
-
6
5
  ocean = env['barge']
7
6
  ssh_keys = ocean.key.all.ssh_keys
8
7
 
9
- say "SSH Keys:"
8
+ say 'SSH Keys:'
10
9
  ssh_keys.each do |key|
11
10
  say "Name: #{key.name}, (id: #{key.id}), fingerprint: #{key.fingerprint}"
12
11
  end
@@ -16,4 +15,3 @@ module Tugboat
16
15
  end
17
16
  end
18
17
  end
19
-
@@ -4,15 +4,15 @@ module Tugboat
4
4
  def call(env)
5
5
  ocean = env['barge']
6
6
 
7
- say "Queuing password reset for #{env["droplet_id"]} #{env["droplet_name"]}...", nil, false
8
- response = ocean.droplet.password_reset env["droplet_id"]
7
+ say "Queuing password reset for #{env['droplet_id']} #{env['droplet_name']}...", nil, false
8
+ response = ocean.droplet.password_reset env['droplet_id']
9
9
 
10
- unless response.success?
10
+ if response.success?
11
+ say 'Password reset successful!', :green
12
+ say 'Your new root password will be emailed to you', :green
13
+ else
11
14
  say "Failed to reset password on Droplet: #{response.message}", :red
12
15
  exit 1
13
- else
14
- say "Password reset successful!", :green
15
- say "Your new root password will be emailed to you", :green
16
16
  end
17
17
 
18
18
  @app.call(env)
@@ -20,4 +20,3 @@ module Tugboat
20
20
  end
21
21
  end
22
22
  end
23
-