foreman_google 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -4
  3. data/app/lib/foreman_google/google_compute_adapter.rb +178 -0
  4. data/app/lib/google_cloud_compute/compute_attributes.rb +98 -0
  5. data/app/lib/google_cloud_compute/compute_collection.rb +23 -0
  6. data/app/lib/google_extensions/attached_disk.rb +22 -0
  7. data/app/models/concerns/foreman_google/host_managed_extensions.rb +11 -0
  8. data/app/models/foreman_google/gce.rb +98 -38
  9. data/app/models/foreman_google/google_compute.rb +68 -58
  10. data/app/views/compute_resources/form/_gce.html.erb +10 -0
  11. data/app/views/compute_resources/show/_gce.html.erb +0 -0
  12. data/app/views/compute_resources_vms/form/gce/_base.html.erb +18 -0
  13. data/app/views/compute_resources_vms/form/gce/_volume.html.erb +5 -0
  14. data/app/views/compute_resources_vms/index/_gce.html.erb +26 -0
  15. data/app/views/compute_resources_vms/show/_gce.html.erb +21 -0
  16. data/app/views/images/form/_gce.html.erb +3 -0
  17. data/db/migrate/20220331113745_foreman_gce_to_foreman_google_gce.rb +24 -0
  18. data/lib/foreman_google/engine.rb +9 -1
  19. data/lib/foreman_google/version.rb +1 -1
  20. data/locale/action_names.rb +6 -0
  21. data/locale/en/foreman_google.edit.po +116 -0
  22. data/locale/en/foreman_google.po +74 -2
  23. data/locale/en/foreman_google.po.time_stamp +0 -0
  24. data/locale/foreman_google.pot +112 -8
  25. data/locale/gemspec.rb +1 -1
  26. data/package.json +7 -7
  27. data/test/fixtures/disks_delete.json +14 -0
  28. data/test/fixtures/disks_get.json +13 -0
  29. data/test/fixtures/disks_insert.json +12 -0
  30. data/test/fixtures/instance.json +1 -1
  31. data/test/fixtures/instance_insert.json +15 -0
  32. data/test/fixtures/instance_list.json +86 -0
  33. data/test/fixtures/instance_set_disk_auto_delete.json +14 -0
  34. data/test/fixtures/operation_error.json +26 -0
  35. data/test/fixtures/operation_get.json +13 -0
  36. data/test/models/foreman_google/gce_test.rb +43 -5
  37. data/test/models/foreman_google/google_compute_test.rb +90 -32
  38. data/test/unit/foreman_google/google_compute_adapter_test.rb +103 -4
  39. data/test/unit/google_extensions/attached_disk_test.rb +17 -0
  40. data/webpack/global_index.js +2 -13
  41. data/webpack/legacy.js +16 -0
  42. metadata +63 -15
  43. data/lib/foreman_google/google_compute_adapter.rb +0 -91
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfa941ac3f2b5a130802d66a7bf5966b42facf344f5020d63ebd3371e79c403f
4
- data.tar.gz: b6d0a39fb9d62e91532a2bfdc157cf79d4ca785826804889055125540d963b04
3
+ metadata.gz: 436414dce3da889d6bcd459132d3f00bb24fa5ea36afc99efa7987836a57b6a9
4
+ data.tar.gz: 05026b5514e98aafe1dd2ccebd9bd52fd04be94af87a78222145b73269f2301f
5
5
  SHA512:
6
- metadata.gz: 4d7a4a449407e4af774f68722b53310c00712a9240c52e1f9e81bad216cbe9989b28e4bdd93d52c93dd52afdce7bf956cbcd5c221ef5f73c65aeeb57d4fd5ea9
7
- data.tar.gz: '008c0c232479d20066e04f6a4066d418120f93c97c26aaaad33a274581f8ecbdeed2acc568f5ae04c211314fb33ab04de58ab6767d6935789d7e1ff311a781ab'
6
+ metadata.gz: ecdf08325bc8ae14f1bb8d6f1af9b71adcdf702239c29f8fd84f80bc8f1266f3f1947a017430eda34dc20cc907a32ef4a51fe8799e5d40562e24c68c5723d8eb
7
+ data.tar.gz: 2980cd7a4e569cdd46009d1560245f0864d05a8d9eab9e401ef7d3da90777326722dc41cf429ac43e1e9c4b87d5746a2bdef9e2354e5c215ae28377ad6be1a74
data/README.md CHANGED
@@ -3,13 +3,25 @@
3
3
  Foreman plugin for Google Compute Engine.
4
4
 
5
5
  ## Installation
6
-
7
- See [How_to_Install_a_Plugin](http://projects.theforeman.org/projects/foreman/wiki/How_to_Install_a_Plugin)
6
+ ```shell
7
+ foreman-installer --enable-foreman-plugin-google
8
+ ```
9
+ Or see [Plugins documentation](https://www.theforeman.org/plugins/#2.Installation)
8
10
  for how to install Foreman plugins
9
11
 
10
12
  ## Usage
11
-
12
- *TODO: Usage here*
13
+ * Create an account and project at [console.cloud.google.com](https://console.cloud.google.com)
14
+ * In _API & Services > Credentials_ create new service account (role: `Editor`)
15
+ * On account detail page go to _Keys_ and create new `JSON` key
16
+ * In Foreman, go to _Infrastructure > Compute Resources_ and create Compute Resource
17
+ ```
18
+ name: <your-name>
19
+ provider: Google
20
+ Google Project ID: <your-project-id>
21
+ Client Email: service account's email
22
+ Certificate Path: JSON file
23
+ Zone: select the zone you want
24
+ ```
13
25
 
14
26
  ## Contributing
15
27
 
@@ -0,0 +1,178 @@
1
+ require 'google-cloud-compute'
2
+
3
+ # rubocop:disable Rails/SkipsModelValidations, Metrics/ClassLength
4
+ module ForemanGoogle
5
+ class GoogleComputeAdapter
6
+ def initialize(auth_json_string:)
7
+ @auth_json = JSON.parse(auth_json_string)
8
+ end
9
+
10
+ def project_id
11
+ @auth_json['project_id']
12
+ end
13
+
14
+ # ------ RESOURCES ------
15
+
16
+ def insert_instance(zone, attrs = {})
17
+ response = resource_client('instances').insert(project: project_id, zone: zone, instance_resource: attrs)
18
+ operation_attrs = { zone: zone, operation: response.operation.id.to_s }
19
+
20
+ wait_for do
21
+ get('zone_operations', operation_attrs).status == :DONE
22
+ end
23
+
24
+ e = get('zone_operations', operation_attrs).error
25
+
26
+ return response unless e
27
+
28
+ raise ::Google::Cloud::Error, e.errors.first.message
29
+ end
30
+
31
+ # Returns an Google::Instance identified by instance_identity within given zone.
32
+ # @param zone [String] eighter full url or just zone name
33
+ # @param instance_identity [String] eighter an instance name or its id
34
+ def instance(zone, instance_identity)
35
+ get('instances', instance: instance_identity, zone: zone)
36
+ end
37
+
38
+ def instances(zone, **attrs)
39
+ list('instances', zone: zone, **attrs)
40
+ end
41
+
42
+ def zones
43
+ list('zones')
44
+ end
45
+
46
+ def networks
47
+ list('networks')
48
+ end
49
+
50
+ def machine_types(zone)
51
+ list('machine_types', zone: zone)
52
+ end
53
+
54
+ def start(zone, instance_identity)
55
+ manage_instance(:start, zone: zone, instance: instance_identity)
56
+ end
57
+
58
+ def stop(zone, instance_identity)
59
+ manage_instance(:stop, zone: zone, instance: instance_identity)
60
+ end
61
+
62
+ def delete_instance(zone, instance_identity)
63
+ manage_instance(:delete, zone: zone, instance: instance_identity)
64
+ end
65
+
66
+ # Setting filter to '(deprecated.state != "DEPRECATED") AND (deprecated.state != "OBSOLETE")'
67
+ # doesn't work and returns empty array, no idea what is happening there
68
+ def images(filter: nil)
69
+ projects = [project_id] + all_projects
70
+ all_images = projects.map { |project| list_images(project, filter: filter) }
71
+ all_images.flatten.reject(&:deprecated)
72
+ end
73
+
74
+ def image(uuid)
75
+ images.find { |img| img.id == uuid }
76
+ end
77
+
78
+ def insert_disk(zone, disk_attrs = {})
79
+ insert('disks', zone, disk_resource: disk_attrs)
80
+ end
81
+
82
+ def disk(zone, name)
83
+ get('disks', disk: name, zone: zone)
84
+ end
85
+
86
+ def delete_disk(zone, disk_name)
87
+ delete('disks', zone, disk: disk_name)
88
+ end
89
+
90
+ def set_disk_auto_delete(zone, instance_identity)
91
+ instance = instance(zone, instance_identity)
92
+ instance.disks.each do |disk|
93
+ manage_instance :set_disk_auto_delete, zone: zone,
94
+ device_name: disk.device_name,
95
+ instance: instance_identity,
96
+ auto_delete: true
97
+ end
98
+ end
99
+
100
+ def serial_port_output(zone, instance_identity)
101
+ manage_instance(:get_serial_port_output, zone: zone, instance: instance_identity)
102
+ end
103
+
104
+ def wait_for
105
+ timeout = 60
106
+ duration = 0
107
+ start = Time.zone.now
108
+
109
+ loop do
110
+ break if yield
111
+
112
+ raise "The specified wait_for timeout (#{timeout} seconds) was exceeded" if duration > timeout
113
+
114
+ sleep(1)
115
+ duration = Time.zone.now - start
116
+ end
117
+
118
+ { duration: duration }
119
+ end
120
+
121
+ private
122
+
123
+ def list(resource_name, **opts)
124
+ response = resource_client(resource_name).list(project: project_id, **opts).response
125
+ response.items
126
+ rescue ::Google::Cloud::Error => e
127
+ raise Foreman::WrappedException.new(e, 'Cannot list Google resource %s', resource_name)
128
+ end
129
+
130
+ def get(resource_name, **opts)
131
+ resource_client(resource_name).get(project: project_id, **opts)
132
+ rescue Google::Cloud::NotFoundError => e
133
+ Foreman::Logging.exception("Could not fetch Google instance [#{opts[:instance]}]", e)
134
+ raise ActiveRecord::RecordNotFound
135
+ rescue ::Google::Cloud::Error => e
136
+ raise Foreman::WrappedException.new(e, 'Could not fetch Google resource %s', resource_name)
137
+ end
138
+
139
+ def insert(resource_name, zone, **opts)
140
+ resource_client(resource_name).insert(project: project_id, zone: zone, **opts)
141
+ rescue ::Google::Cloud::Error => e
142
+ raise Foreman::WrappedException.new(e, 'Could not create Google resource %s', resource_name)
143
+ end
144
+
145
+ def delete(resource_name, zone, **opts)
146
+ resource_client(resource_name).delete(project: project_id, zone: zone, **opts)
147
+ rescue ::Google::Cloud::Error => e
148
+ raise Foreman::WrappedException.new(e, 'Could not delete Google resource %s', resource_name)
149
+ end
150
+
151
+ def list_images(project, **opts)
152
+ resource_name = 'images'
153
+ response = resource_client(resource_name).list(project: project, **opts).response
154
+ response.items
155
+ rescue ::Google::Cloud::Error => e
156
+ raise Foreman::WrappedException.new(e, 'Cannot list Google resource %s', resource_name)
157
+ end
158
+
159
+ def manage_instance(action, **opts)
160
+ resource_client('instances').send(action, project: project_id, **opts)
161
+ rescue ::Google::Cloud::Error => e
162
+ raise Foreman::WrappedException.new(e, 'Could not %s Google resource %s', action.to_s, resource_name)
163
+ end
164
+
165
+ def resource_client(resource_name)
166
+ ::Google::Cloud::Compute.public_send(resource_name) do |config|
167
+ config.credentials = @auth_json
168
+ end
169
+ end
170
+
171
+ def all_projects
172
+ %w[centos-cloud cos-cloud coreos-cloud debian-cloud opensuse-cloud
173
+ rhel-cloud rhel-sap-cloud suse-cloud suse-sap-cloud
174
+ ubuntu-os-cloud windows-cloud windows-sql-cloud].freeze
175
+ end
176
+ end
177
+ end
178
+ # rubocop:enable Rails/SkipsModelValidations, Metrics/ClassLength
@@ -0,0 +1,98 @@
1
+ module GoogleCloudCompute
2
+ class ComputeAttributes
3
+ def initialize(client)
4
+ @client = client
5
+ end
6
+
7
+ def for_new(args)
8
+ name = parameterize_name(args[:name])
9
+ network = args[:network] || 'default'
10
+ associate_external_ip = ActiveModel::Type::Boolean.new.cast(args[:associate_external_ip])
11
+
12
+ { name: name, hostname: name,
13
+ machine_type: args[:machine_type],
14
+ network: network, associate_external_ip: associate_external_ip,
15
+ network_interfaces: construct_network(network, associate_external_ip, args[:network_interfaces] || []),
16
+ image_id: args[:image_id],
17
+ volumes: construct_volumes(name, args[:image_id], args[:volumes]),
18
+ metadata: construct_metadata(args) }
19
+ end
20
+
21
+ def for_create(instance)
22
+ {
23
+ name: instance.name,
24
+ machine_type: "zones/#{instance.zone}/machineTypes/#{instance.machine_type}",
25
+ disks: instance.volumes.map.with_index { |vol, i| { source: "zones/#{instance.zone}/disks/#{vol.device_name}", boot: i.zero? } },
26
+ network_interfaces: instance.network_interfaces,
27
+ metadata: instance.metadata,
28
+ }
29
+ end
30
+
31
+ def for_instance(instance)
32
+ {
33
+ name: instance.name, hostname: instance.name,
34
+ creation_timestamp: instance.creation_timestamp.to_datetime,
35
+ zone_name: instance.zone.split('/').last,
36
+ machine_type: instance.machine_type,
37
+ network: instance.network_interfaces[0].network.split('/').last,
38
+ network_interfaces: instance.network_interfaces,
39
+ volumes: instance.disks, metadata: instance.metadata
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def parameterize_name(name)
46
+ name&.parameterize || "foreman-#{Time.now.to_i}"
47
+ end
48
+
49
+ def construct_network(network_name, associate_external_ip, network_interfaces)
50
+ # handle network_interface for external ip
51
+ # assign ephemeral external IP address using associate_external_ip
52
+ if associate_external_ip
53
+ network_interfaces = [{ network: 'global/networks/default' }] if network_interfaces.empty?
54
+ access_config = { name: 'External NAT', type: 'ONE_TO_ONE_NAT' }
55
+
56
+ # Note - no support for external_ip from foreman
57
+ # access_config[:nat_ip] = external_ip if external_ip
58
+ network_interfaces[0][:access_configs] = [access_config]
59
+ return network_interfaces
60
+ end
61
+
62
+ network = "https://compute.googleapis.com/compute/v1/projects/#{@client.project_id}/global/networks/#{network_name}"
63
+ [{ network: network }]
64
+ end
65
+
66
+ def load_image(image_id)
67
+ return unless image_id
68
+
69
+ @client.image(image_id.to_i)
70
+ end
71
+
72
+ def construct_volumes(vm_name, image_id, volumes = [])
73
+ return [Google::Cloud::Compute::V1::AttachedDisk.new(disk_size_gb: 20)] if volumes.empty?
74
+
75
+ image = load_image(image_id)
76
+
77
+ attached_disks = volumes.map.with_index do |vol_attrs, i|
78
+ name = "#{vm_name}-disk#{i + 1}"
79
+ size = (vol_attrs[:size_gb] || vol_attrs[:disk_size_gb]).to_i
80
+
81
+ Google::Cloud::Compute::V1::AttachedDisk.new(device_name: name, disk_size_gb: size)
82
+ end
83
+
84
+ attached_disks.first.source = image&.self_link if image&.self_link
85
+ attached_disks
86
+ end
87
+
88
+ # Note - GCE only supports cloud-init for Container Optimized images and
89
+ # for custom images with cloud-init setup
90
+ def construct_metadata(args)
91
+ ssh_keys = { key: 'ssh-keys', value: "#{args[:username]}:#{args[:public_key]}" }
92
+
93
+ return { items: [ssh_keys] } if args[:user_data].blank?
94
+
95
+ { items: [ssh_keys, { key: 'user-data', value: args[:user_data] }] }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,23 @@
1
+ module GoogleCloudCompute
2
+ class ComputeCollection
3
+ include Enumerable
4
+
5
+ def initialize(client, zone, attrs)
6
+ instances = client.instances(zone, attrs)
7
+ @virtual_machines = instances.map do |vm|
8
+ ForemanGoogle::GoogleCompute.new client: client,
9
+ zone: zone,
10
+ identity: vm.id,
11
+ instance: vm
12
+ end
13
+ end
14
+
15
+ def each(&block)
16
+ @virtual_machines.each(&block)
17
+ end
18
+
19
+ def all(_opts = {})
20
+ @virtual_machines
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ module GoogleExtensions
2
+ module AttachedDisk
3
+ def persisted?
4
+ end
5
+
6
+ def id
7
+ end
8
+
9
+ def _delete
10
+ end
11
+
12
+ def insert_attrs
13
+ attrs = { name: device_name, size_gb: disk_size_gb }
14
+ attrs[:source_image] = source if source.present?
15
+ attrs
16
+ end
17
+
18
+ def size_gb
19
+ disk_size_gb
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ module ForemanGoogle
2
+ module HostManagedExtensions
3
+ def ip_addresses
4
+ vm&.ip_addresses || []
5
+ end
6
+
7
+ def vm_ip_address
8
+ vm&.vm_ip_address
9
+ end
10
+ end
11
+ end
@@ -1,42 +1,33 @@
1
1
  require 'foreman_google/google_compute_adapter'
2
2
 
3
+ # rubocop:disable Rails/InverseOf, Metrics/ClassLength
3
4
  module ForemanGoogle
4
5
  class GCE < ::ComputeResource
5
- def self.available?
6
- true
7
- end
8
-
9
- def self.provider_friendly_name
10
- 'Google'
11
- end
6
+ has_one :key_pair, foreign_key: :compute_resource_id, dependent: :destroy
7
+ before_create :setup_key_pair
8
+ validates :password, :zone, presence: true
12
9
 
13
- def user_data_supported?
10
+ def self.available?
14
11
  true
15
12
  end
16
13
 
17
- def test_connection(options = {})
18
- end
19
-
20
14
  def to_label
15
+ "#{name} (#{zone}-#{provider_friendly_name})"
21
16
  end
22
17
 
23
18
  def capabilities
24
19
  %i[image new_volume]
25
20
  end
26
21
 
22
+ def provided_attributes
23
+ super.merge({ ip: :vm_ip_address })
24
+ end
25
+
27
26
  def zones
28
27
  client.zones.map(&:name)
29
28
  end
30
29
  alias_method :available_zones, :zones
31
30
 
32
- def zone
33
- url
34
- end
35
-
36
- def zone=(zone)
37
- self.url = zone
38
- end
39
-
40
31
  def networks
41
32
  client.networks.map(&:name)
42
33
  end
@@ -50,17 +41,12 @@ module ForemanGoogle
50
41
  end
51
42
  alias_method :available_flavors, :machine_types
52
43
 
53
- def disks
44
+ def zone
45
+ url
54
46
  end
55
47
 
56
- # def interfaces_attrs_name
57
- # super # :interfaces
58
- # end
59
-
60
- # This should return interface compatible with Fog::Server
61
- # implemented by ForemanGoogle::Compute
62
- def find_vm_by_uuid(uuid)
63
- GoogleCompute.new(client: client, zone: zone, identity: uuid.to_s)
48
+ def zone=(zone)
49
+ self.url = zone
64
50
  end
65
51
 
66
52
  def new_vm(args = {})
@@ -73,35 +59,81 @@ module ForemanGoogle
73
59
  GoogleCompute.new(client: client, zone: zone, args: vm_args)
74
60
  end
75
61
 
62
+ def create_vm(args = {})
63
+ ssh_args = { username: find_os_image(args[:image_id])&.username, public_key: key_pair.public }
64
+ vm = new_vm(args.merge(ssh_args))
65
+
66
+ vm.create_volumes
67
+ vm.create_instance
68
+ vm.set_disk_auto_delete
69
+
70
+ find_vm_by_uuid vm.hostname
71
+ rescue ::Google::Cloud::Error => e
72
+ vm.destroy_volumes
73
+ raise Foreman::WrappedException.new(e, 'Cannot insert instance!')
74
+ end
75
+
76
+ def find_vm_by_uuid(uuid)
77
+ GoogleCompute.new(client: client, zone: zone, identity: uuid.to_s)
78
+ end
79
+
76
80
  def destroy_vm(uuid)
81
+ client.set_disk_auto_delete(zone, uuid)
82
+ client.delete_instance(zone, uuid)
83
+ rescue ActiveRecord::RecordNotFound
84
+ # if the VM does not exists, we don't really care.
85
+ true
77
86
  end
78
87
 
79
- def create_volumes(args)
88
+ def available_images(filter: nil)
89
+ client.images(filter: filter)
80
90
  end
81
91
 
82
- def create_vm(args = {})
83
- new_vm(args)
84
- create_volumes(args)
85
- # TBD
92
+ def self.model_name
93
+ ComputeResource.model_name
86
94
  end
87
95
 
88
- def vm_options(args)
96
+ def setup_key_pair
97
+ require 'sshkey'
98
+
99
+ key = ::SSHKey.generate
100
+ build_key_pair name: "foreman-#{id}#{Foreman.uuid}", secret: key.private_key, public: key.ssh_public_key
89
101
  end
90
102
 
91
- def new_volume(attrs = {})
103
+ def self.provider_friendly_name
104
+ 'Google'
92
105
  end
93
106
 
94
- def normalize_vm_attrs(vm_attrs)
107
+ def user_data_supported?
108
+ true
109
+ end
110
+
111
+ def new_volume(attrs = {})
112
+ default_attrs = { disk_size_gb: 20 }
113
+ Google::Cloud::Compute::V1::AttachedDisk.new(**attrs.merge(default_attrs))
95
114
  end
96
115
 
97
116
  def console(uuid)
117
+ vm = find_vm_by_uuid(uuid)
118
+
119
+ if vm.ready?
120
+ {
121
+ 'output' => vm.serial_port_output, 'timestamp' => Time.now.utc,
122
+ :type => 'log', :name => vm.name
123
+ }
124
+ else
125
+ raise ::Foreman::Exception,
126
+ N_('console is not available at this time because the instance is powered off')
127
+ end
98
128
  end
99
129
 
100
130
  def associated_host(vm)
131
+ associate_by('ip', [vm.public_ip_address, vm.private_ip_address])
101
132
  end
102
133
 
103
- def available_images(filter: nil)
104
- client.images(filter: filter)
134
+ def vms(attrs = {})
135
+ filtered_attrs = attrs.except(:eager_loading)
136
+ GoogleCloudCompute::ComputeCollection.new(client, zone, filtered_attrs)
105
137
  end
106
138
 
107
139
  # ----# Google specific #-----
@@ -110,10 +142,38 @@ module ForemanGoogle
110
142
  client.project_id
111
143
  end
112
144
 
145
+ def vm_ready(vm)
146
+ vm.wait_for do
147
+ vm.reload
148
+ vm.ready?
149
+ end
150
+ end
151
+
113
152
  private
114
153
 
115
154
  def client
116
155
  @client ||= ForemanGoogle::GoogleComputeAdapter.new(auth_json_string: password)
117
156
  end
157
+
158
+ def set_vm_volumes_attributes(vm, vm_attrs)
159
+ return vm_attrs unless vm.respond_to?(:volumes)
160
+
161
+ vm_attrs[:volumes_attributes] = Hash[vm.volumes.each_with_index.map { |volume, idx| [idx.to_s, volume.to_h] }]
162
+
163
+ vm_attrs
164
+ end
165
+
166
+ def find_os_image(uuid)
167
+ os_image = images.find_by(uuid: uuid)
168
+ gce_image = client.image(uuid.to_i)
169
+
170
+ raise ::Foreman::Exception, N_('Missing an image for operating system!') if os_image.nil?
171
+ if gce_image.nil?
172
+ raise ::Foreman::Exception, N_("GCE image [#{uuid.to_i}] for #{os_image.name} image not found in the cloud!")
173
+ end
174
+
175
+ os_image
176
+ end
118
177
  end
119
178
  end
179
+ # rubocop:enable Rails/InverseOf, Metrics/ClassLength