foreman_rh_cloud 13.0.4 → 13.0.5

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 (26) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/insights_cloud/package_profile_upload_extensions.rb +2 -3
  3. data/app/services/foreman_rh_cloud/cert_auth.rb +13 -3
  4. data/app/services/foreman_rh_cloud/insights_api_forwarder.rb +3 -1
  5. data/app/services/foreman_rh_cloud/tags_auth.rb +2 -1
  6. data/lib/foreman_inventory_upload/async/create_missing_insights_facets.rb +29 -0
  7. data/lib/foreman_inventory_upload/async/generate_host_report.rb +20 -0
  8. data/lib/foreman_inventory_upload/async/generate_report_job.rb +1 -1
  9. data/lib/foreman_inventory_upload/async/host_inventory_report_job.rb +39 -0
  10. data/lib/foreman_inventory_upload/async/single_host_report_job.rb +20 -0
  11. data/lib/foreman_inventory_upload/async/upload_report_job.rb +2 -1
  12. data/lib/foreman_inventory_upload/generators/fact_helpers.rb +2 -2
  13. data/lib/foreman_inventory_upload/generators/slice.rb +3 -3
  14. data/lib/foreman_inventory_upload/scripts/uploader.sh.erb +7 -1
  15. data/lib/foreman_rh_cloud/plugin.rb +9 -9
  16. data/lib/foreman_rh_cloud/version.rb +1 -1
  17. data/lib/insights_cloud/async/vmaas_reposcan_sync.rb +8 -1
  18. data/lib/tasks/rh_cloud_inventory.rake +14 -32
  19. data/package.json +1 -1
  20. data/test/unit/fact_helpers_test.rb +47 -0
  21. data/test/unit/lib/insights_cloud/async/vmaas_reposcan_sync_test.rb +12 -2
  22. data/test/unit/slice_generator_test.rb +57 -0
  23. data/webpack/InsightsHostDetailsTab/InsightsTotalRiskChart.js +57 -21
  24. data/webpack/InsightsHostDetailsTab/__tests__/InsightsTotalRiskChart.test.js +194 -0
  25. metadata +6 -2
  26. data/app/services/foreman_rh_cloud/gateway_request.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07f9c21862525b394bf03e7490168fc1f0d66e8af832c4ce8e3f8d5796bde269
4
- data.tar.gz: b88f3038b40d623c2d5df92f801d328bd568f9360d17760a6b2c0dff3808bf11
3
+ metadata.gz: ece8ef4c631103e6c100e8aa8029d0dc3473911e8f276267404c49190b800a47
4
+ data.tar.gz: cd9f6ab8349eec0d93cdf29fa1b80f8806d7175bc8c0f8af192f1ab4d83214cc
5
5
  SHA512:
6
- metadata.gz: 8773fa656f0fbd4f50833faf50e7d4a88bca97f9b39d0c62d944ba2bbfbef9fd18f5adaa1647e8d4ece97a0fca330935d1acb46fd54359428aa8f43cb3b9d403
7
- data.tar.gz: 98c7cec90e073a517582fcd789add89684e2788c425bab6d2d4793611277d65ce7d21e3b0fc18b66beeb8fd553407c9172308f89e60e7516bdacd49bdfcbadaf
6
+ metadata.gz: 2e7cfc9880d367c0e558ca87136a66af84ffcdd1d82a852b956cd90c7988209280509325a9442bf6f5ebcbdd58d1cd4e29d87766c79eb4f9830c5f3e9452356c
7
+ data.tar.gz: a5f4543f5fea79d337002d9b17d4cc51830b2e3b2102426ba0a21b25c875e1a09109816fe1c8f4766d3bc52ae3f6a517e9da8b35faad999f59eeec396a75f628
@@ -15,11 +15,10 @@ module InsightsCloud
15
15
  logger.debug("Generating host-specific report for host #{@host.name}")
16
16
 
17
17
  ForemanTasks.async_task(
18
- ForemanInventoryUpload::Async::GenerateReportJob,
18
+ ForemanInventoryUpload::Async::SingleHostReportJob,
19
19
  ForemanInventoryUpload.generated_reports_folder,
20
20
  @host.organization_id,
21
- false,
22
- "id=#{@host.id}"
21
+ @host.id
23
22
  )
24
23
 
25
24
  # in IoP case, the hosts are identified by the sub-man ID, and we can assume they already
@@ -11,11 +11,21 @@ module ForemanRhCloud
11
11
 
12
12
  def execute_cloud_request(params)
13
13
  organization = params.delete(:organization)
14
- certs = ForemanRhCloud.with_iop_smart_proxy? ? foreman_certificate : candlepin_id_cert(organization)
15
- final_params = {
14
+ # Cache the value of with_iop_smart_proxy? to avoid multiple calls to the database
15
+ with_iop_smart_proxy = ForemanRhCloud.with_iop_smart_proxy?
16
+ certs = with_iop_smart_proxy ? foreman_certificate : candlepin_id_cert(organization)
17
+ default_params = {
16
18
  ssl_client_cert: OpenSSL::X509::Certificate.new(certs[:cert]),
17
19
  ssl_client_key: OpenSSL::PKey.read(certs[:key]),
18
- }.deep_merge(params)
20
+ }
21
+
22
+ if with_iop_smart_proxy && organization&.label
23
+ default_params[:headers] = {
24
+ 'X-Org-Id' => organization&.label,
25
+ }
26
+ end
27
+
28
+ final_params = default_params.deep_merge(params)
19
29
 
20
30
  super(final_params)
21
31
  end
@@ -2,7 +2,7 @@ require 'rest-client'
2
2
 
3
3
  module ForemanRhCloud
4
4
  class InsightsApiForwarder
5
- include ForemanRhCloud::GatewayRequest
5
+ include ForemanRhCloud::CertAuth
6
6
 
7
7
  SCOPED_REQUESTS = [
8
8
  { test: %r{api/vulnerability/v1/vulnerabilities/cves}, tag_name: :tags },
@@ -26,6 +26,8 @@ module ForemanRhCloud
26
26
 
27
27
  request_opts = prepare_request_opts(original_request, path, forward_payload, forward_params)
28
28
 
29
+ request_opts[:organization] = organization
30
+
29
31
  logger.debug("Sending request to: #{request_opts[:url]}")
30
32
 
31
33
  execute_cloud_request(request_opts)
@@ -1,6 +1,6 @@
1
1
  module ForemanRhCloud
2
2
  class TagsAuth
3
- include GatewayRequest
3
+ include CertAuth
4
4
 
5
5
  TAG_NAMESPACE = 'sat_iam'.freeze
6
6
  TAG_SHORT_NAME = 'scope'.freeze
@@ -24,6 +24,7 @@ module ForemanRhCloud
24
24
 
25
25
  payload = tags_query_payload
26
26
  params = {
27
+ organization: @org,
27
28
  method: :post,
28
29
  url: "#{InsightsCloud.gateway_url}/tags",
29
30
  headers: {
@@ -0,0 +1,29 @@
1
+ module ForemanInventoryUpload
2
+ module Async
3
+ class CreateMissingInsightsFacets < ::Actions::EntryAction
4
+ def plan(organization_id)
5
+ plan_self(organization_id: organization_id)
6
+ end
7
+
8
+ def run
9
+ organization = ::Organization.find(input[:organization_id])
10
+ hosts_without_facets = ::ForemanInventoryUpload::Generators::Queries.for_org(organization, hosts_query: 'null? insights_uuid')
11
+ facet_count = hosts_without_facets.count
12
+ hosts_without_facets.each do |batch|
13
+ facets = batch.pluck(:id, 'katello_subscription_facets.uuid').map do |host_id, uuid|
14
+ {
15
+ host_id: host_id,
16
+ uuid: uuid,
17
+ }
18
+ end
19
+ # We don't need to validate the facets here as we create the necessary fields.
20
+ # rubocop:disable Rails/SkipsModelValidations
21
+ InsightsFacet.upsert_all(facets, unique_by: :host_id) unless facets.empty?
22
+ # rubocop:enable Rails/SkipsModelValidations
23
+ end
24
+ output[:result] = facet_count.zero? ? _("There were no missing Insights facets") : format(_("Missing Insights facets created: %s"), facet_count)
25
+ Rails.logger.info output[:result]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ module ForemanInventoryUpload
2
+ module Async
3
+ class GenerateHostReport < ::Actions::EntryAction
4
+ def plan(base_folder, organization_id, filter)
5
+ plan_self(
6
+ base_folder: base_folder,
7
+ organization_id: organization_id,
8
+ filter: filter
9
+ )
10
+ input[:target] = File.join(base_folder, ForemanInventoryUpload.facts_archive_name(input[:organization_id], input[:filter]))
11
+ end
12
+
13
+ def run
14
+ archived_report_generator = ForemanInventoryUpload::Generators::ArchivedReport.new(input[:target])
15
+ archived_report_generator.render(organization: input[:organization_id], filter: input[:filter])
16
+ output[:result] = "Generated #{input[:target]} for organization id #{input[:organization_id]}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -5,7 +5,7 @@ module ForemanInventoryUpload
5
5
  "report_for_#{label}"
6
6
  end
7
7
 
8
- def plan(base_folder, organization_id, disconnected, hosts_filter = nil)
8
+ def plan(base_folder, organization_id, disconnected = false, hosts_filter = nil)
9
9
  sequence do
10
10
  super(
11
11
  GenerateReportJob.output_label("#{organization_id}#{hosts_filter.empty? ? nil : "[#{hosts_filter.to_s.parameterize}]"}"),
@@ -0,0 +1,39 @@
1
+ module ForemanInventoryUpload
2
+ module Async
3
+ class HostInventoryReportJob < ::Actions::EntryAction
4
+ def plan(base_folder, organization_id, hosts_filter = "", upload = true)
5
+ sequence do
6
+ plan_action(
7
+ GenerateHostReport,
8
+ base_folder,
9
+ organization_id,
10
+ hosts_filter
11
+ )
12
+ if upload
13
+ plan_action(
14
+ QueueForUploadJob,
15
+ base_folder,
16
+ ForemanInventoryUpload.facts_archive_name(organization_id, hosts_filter),
17
+ organization_id
18
+ )
19
+ end
20
+
21
+ if ForemanRhCloud.with_iop_smart_proxy?
22
+ plan_action(
23
+ CreateMissingInsightsFacets,
24
+ organization_id
25
+ )
26
+ end
27
+ end
28
+ end
29
+
30
+ def humanized_name
31
+ _("Host inventory report job")
32
+ end
33
+
34
+ def organization_id
35
+ input[:organization_id]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ module ForemanInventoryUpload
2
+ module Async
3
+ class SingleHostReportJob < HostInventoryReportJob
4
+ def plan(base_folder, organization_id, host_id)
5
+ input[:host_id] = host_id
6
+ super(base_folder, organization_id, "id=#{input[:host_id]}")
7
+ end
8
+
9
+ def hostname(host_id)
10
+ host = ::Host.find_by(id: host_id)
11
+ host&.name
12
+ end
13
+
14
+ def humanized_name
15
+ hostname_result = hostname(input[:host_id])
16
+ hostname_result.present? ? format(_("Single-host report job for host %s"), hostname_result) : _("Single-host report job")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -48,7 +48,8 @@ module ForemanInventoryUpload
48
48
  def env
49
49
  env_vars = super.merge(
50
50
  'FILES' => filename,
51
- 'CER_PATH' => @cer_path
51
+ 'CER_PATH' => @cer_path,
52
+ 'ORG_ID' => organization.label
52
53
  )
53
54
 
54
55
  http_proxy_string = ForemanRhCloud.http_proxy_string
@@ -59,7 +59,7 @@ module ForemanInventoryUpload
59
59
  def obfuscate_hostname?(host)
60
60
  # Returns true if hostname obfuscation should be applied for a given host, based on hierarchy:
61
61
  # 1. Global setting for hostname obfuscation.
62
- return true if Setting[:obfuscate_inventory_hostnames]
62
+ return true if Setting[:obfuscate_inventory_hostnames] && !ForemanRhCloud.with_iop_smart_proxy?
63
63
 
64
64
  insights_client_setting = fact_value(host, 'insights_client::obfuscate_hostname_enabled')
65
65
  insights_client_setting = ActiveModel::Type::Boolean.new.cast(insights_client_setting)
@@ -99,7 +99,7 @@ module ForemanInventoryUpload
99
99
  def obfuscate_ips?(host)
100
100
  # Returns true if IP obfuscation should be applied for a given host, based on hierarchy:
101
101
  # 1. Global setting for IP obfuscation.
102
- return true if Setting[:obfuscate_inventory_ips]
102
+ return true if Setting[:obfuscate_inventory_ips] && !ForemanRhCloud.with_iop_smart_proxy?
103
103
 
104
104
  insights_client_ipv4_setting = fact_value(host, 'insights_client::obfuscate_ipv4_enabled')
105
105
  insights_client_ipv6_setting = fact_value(host, 'insights_client::obfuscate_ipv6_enabled')
@@ -90,7 +90,7 @@ module ForemanInventoryUpload
90
90
  def report_host(host)
91
91
  host_ips_cache = host_ips(host)
92
92
  @stream.object do
93
- if Setting[:insights_minimal_data_collection]
93
+ if Setting[:insights_minimal_data_collection] && !ForemanRhCloud.with_iop_smart_proxy?
94
94
  insights_minimal_data_collection(host)
95
95
  else
96
96
  @stream.simple_field('fqdn', fqdn(host))
@@ -205,7 +205,7 @@ module ForemanInventoryUpload
205
205
  end.join(', '))
206
206
  end
207
207
  end
208
- if !Setting[:insights_minimal_data_collection] && !Setting[:exclude_installed_packages]
208
+ if ForemanRhCloud.with_iop_smart_proxy? || (!Setting[:insights_minimal_data_collection] && !Setting[:exclude_installed_packages])
209
209
  @stream.array_field('installed_packages') do
210
210
  first = true
211
211
  host.installed_packages.each do |package|
@@ -256,7 +256,7 @@ module ForemanInventoryUpload
256
256
 
257
257
  @stream.array_field('yum_repos') do
258
258
  host.content_facet.bound_repositories.each_with_index do |repo, index|
259
- report_yum_repo(host.content_source.load_balancer_pulp_content_url, repo)
259
+ report_yum_repo(host.content_source&.load_balancer_pulp_content_url || ::SmartProxy.pulp_primary.pulp_content_url, repo)
260
260
  @stream.comma unless index == host.content_facet.bound_repositories.count - 1
261
261
  end
262
262
  end
@@ -27,6 +27,12 @@ else
27
27
  AUTH_VAL="\"$RH_USERNAME\":\"$RH_PASSWORD\""
28
28
  fi
29
29
 
30
+ ORG_HEADER=()
31
+ if [ -n "$ORG_ID" ]
32
+ then
33
+ + ORG_HEADER=("-H" "X-Org-Id: $ORG_ID")
34
+ fi
35
+
30
36
  # /tmp/a b/x.pem
31
37
  # curl --cert /tmp/a\ b/x.pem
32
38
 
@@ -36,7 +42,7 @@ mkdir -p $DONE_DIR
36
42
 
37
43
  for f in $FILES
38
44
  do
39
- curl -k -vvv -# --fail -F "file=@$f;type=application/vnd.redhat.qpc.tar+tgz" $DEST "$AUTH_KEY" "$AUTH_VAL"
45
+ curl -k -vvv -# --fail -F "file=@$f;type=application/vnd.redhat.qpc.tar+tgz" $DEST "$AUTH_KEY" "$AUTH_VAL" "${ORG_HEADER[@]}"
40
46
  status=$?
41
47
  if [ $status -eq 0 ]; then
42
48
  mv $f $DONE_DIR
@@ -13,15 +13,15 @@ module ForemanRhCloud
13
13
 
14
14
  settings do
15
15
  category(:rh_cloud, N_('Insights')) do
16
- setting('allow_auto_inventory_upload', type: :boolean, description: N_('Enable automatic upload of your host inventory to the Red Hat cloud'), default: true, full_name: N_('Automatic inventory upload'))
17
- setting('allow_auto_insights_sync', type: :boolean, description: N_('Enable automatic synchronization of Insights recommendations from the Red Hat cloud'), default: true, full_name: N_('Synchronize recommendations Automatically'))
18
- setting('allow_auto_insights_mismatch_delete', type: :boolean, description: N_('Enable automatic deletion of mismatched host records from the Red Hat cloud'), default: false, full_name: N_('Automatic mismatch deletion'))
19
- setting('obfuscate_inventory_hostnames', type: :boolean, description: N_('Obfuscate host names sent to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored because host names are not included in the report.)'), default: false, full_name: N_('Obfuscate host names'))
20
- setting('obfuscate_inventory_ips', type: :boolean, description: N_('Obfuscate ipv4 addresses sent to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored because host IPv4 addresses are not included in the report.)'), default: false, full_name: N_('Obfuscate host ipv4 addresses.'))
21
- setting('exclude_installed_packages', type: :boolean, description: N_('Exclude installed packages from being uploaded to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored and installed packages are always excluded.)'), default: false, full_name: N_("Exclude installed packages"))
22
- setting('include_parameter_tags', type: :boolean, description: N_('Should import include parameter tags from Foreman?'), default: false, full_name: N_('Include parameters in insights-client reports'))
23
- setting('rhc_instance_id', type: :string, description: N_('RHC daemon id'), default: nil, full_name: N_('ID of the RHC(Yggdrasil) daemon'))
24
- setting('insights_minimal_data_collection', type: :boolean, default: false, full_name: N_('Minimal data collection'), description: N_('Only include the minimum required data in inventory reports for uploading to Red Hat cloud. When this is true, installed packages are excluded from the report regardless of the exclude_installed_packages setting, and host names and IPv4 addresses are excluded from the report regardless of obfuscation settings.'))
16
+ setting('allow_auto_inventory_upload', type: :boolean, description: N_('Enable automatic upload of your host inventory to the Red Hat cloud. Ignored when using local Insights.'), default: true, full_name: N_('Automatic inventory upload'))
17
+ setting('allow_auto_insights_sync', type: :boolean, description: N_('Enable automatic synchronization of Insights recommendations from the Red Hat cloud. Ignored when using local Insights.'), default: true, full_name: N_('Synchronize recommendations Automatically'))
18
+ setting('allow_auto_insights_mismatch_delete', type: :boolean, description: N_('Enable automatic deletion of mismatched host records from the Red Hat cloud. Ignored when using local Insights.'), default: false, full_name: N_('Automatic mismatch deletion'))
19
+ setting('obfuscate_inventory_hostnames', type: :boolean, description: N_('Obfuscate host names sent to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored because host names are not included in the report.) Ignored when using local Insights.'), default: false, full_name: N_('Obfuscate host names'))
20
+ setting('obfuscate_inventory_ips', type: :boolean, description: N_('Obfuscate ipv4 addresses sent to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored because host IPv4 addresses are not included in the report.) Ignored when using local Insights.'), default: false, full_name: N_('Obfuscate host ipv4 addresses.'))
21
+ setting('exclude_installed_packages', type: :boolean, description: N_('Exclude installed packages from being uploaded to the Red Hat cloud. (If insights_minimal_data_collection is set to true, this setting is ignored and installed packages are always excluded.) Ignored when using local Insights.'), default: false, full_name: N_("Exclude installed packages"))
22
+ setting('include_parameter_tags', type: :boolean, description: N_('Should import include parameter tags from Foreman? Ignored when using local Insights.'), default: false, full_name: N_('Include parameters in insights-client reports'))
23
+ setting('rhc_instance_id', type: :string, description: N_('RHC daemon id. Ignored when using local Insights.'), default: nil, full_name: N_('ID of the RHC(Yggdrasil) daemon'))
24
+ setting('insights_minimal_data_collection', type: :boolean, default: false, full_name: N_('Minimal data collection'), description: N_('Only include the minimum required data in inventory reports for uploading to Red Hat cloud. When this is true, installed packages are excluded from the report regardless of the exclude_installed_packages setting, and host names and IPv4 addresses are excluded from the report regardless of obfuscation settings. Ignored when using local Insights.'))
25
25
  end
26
26
  end
27
27
 
@@ -1,3 +1,3 @@
1
1
  module ForemanRhCloud
2
- VERSION = '13.0.4'.freeze
2
+ VERSION = '13.0.5'.freeze
3
3
  end
@@ -23,13 +23,16 @@ module InsightsCloud
23
23
  return
24
24
  end
25
25
 
26
- plan_self
26
+ organization_id = Katello::Repository.find(repo_id).organization_id
27
+
28
+ plan_self(organization_id: organization_id)
27
29
  end
28
30
 
29
31
  def run
30
32
  url = ::InsightsCloud.vmaas_reposcan_sync_url
31
33
 
32
34
  response = execute_cloud_request(
35
+ organization: organization,
33
36
  method: :put,
34
37
  url: url,
35
38
  headers: { 'Content-Type' => 'application/json' }
@@ -61,6 +64,10 @@ module InsightsCloud
61
64
  Dynflow::Action::Rescue::Skip
62
65
  end
63
66
 
67
+ def organization
68
+ @organization ||= Organization.find(input[:organization_id])
69
+ end
70
+
64
71
  private
65
72
 
66
73
  def logger
@@ -9,22 +9,20 @@ namespace :rh_cloud_inventory do
9
9
  else
10
10
  organizations = [Organization.where(:id => ENV['organization_id']).first]
11
11
  end
12
- disconnected = ForemanRhCloud.with_iop_smart_proxy?
13
12
  User.as_anonymous_admin do
14
13
  organizations.each do |organization|
15
14
  ForemanTasks.async_task(
16
15
  ForemanInventoryUpload::Async::GenerateReportJob,
17
16
  ForemanInventoryUpload.generated_reports_folder,
18
- organization.id,
19
- disconnected
17
+ organization.id
20
18
  )
21
19
  puts "Generated and uploaded inventory report for organization '#{organization.name}'"
22
20
  end
23
21
  end
24
22
  end
25
23
  desc 'Generate inventory report to be sent to Red Hat cloud'
26
- task generate: :environment do
27
- organizations = [ENV['organization_id']]
24
+ task generate: [:environment, 'dynflow:client'] do
25
+ organization_ids = [ENV['organization_id']]
28
26
  base_folder = ENV['target'] || Dir.pwd
29
27
  filter = ENV['hosts_filter']
30
28
 
@@ -34,37 +32,22 @@ namespace :rh_cloud_inventory do
34
32
  puts "Using #{base_folder} for the output"
35
33
  end
36
34
 
37
- if organizations.empty?
35
+ if organization_ids.empty?
38
36
  puts "Must specify organization_id"
39
37
  return
40
38
  end
41
39
 
42
40
  User.as_anonymous_admin do
43
- organizations.each do |organization|
44
- target = File.join(base_folder, ForemanInventoryUpload.facts_archive_name(organization, filter))
45
- archived_report_generator = ForemanInventoryUpload::Generators::ArchivedReport.new(target, Logger.new(STDOUT))
46
- archived_report_generator.render(organization: organization, filter: filter)
47
- puts "Successfully generated #{target} for organization id #{organization}"
48
- puts "Check the Uploading tab for report uploading status." if Setting[:subscription_connection_enabled]
49
-
50
- next unless ForemanRhCloud.with_iop_smart_proxy?
51
-
52
- puts 'Creating missing insights facets'
53
- hosts_without_facets = ForemanInventoryUpload::Generators::Queries.for_org(organization, hosts_query: 'null? insights_uuid')
54
- hosts_without_facets.each do |batch|
55
- facets = batch.pluck(:id, 'katello_subscription_facets.uuid').map do |host_id, uuid|
56
- {
57
- host_id: host_id,
58
- uuid: uuid,
59
- }
60
- end
61
- # We don't need to validate the facets here as we create the necessary fields.
62
- # rubocop:disable Rails/SkipsModelValidations
63
- InsightsFacet.upsert_all(facets, unique_by: :host_id) unless facets.empty?
64
- # rubocop:enable Rails/SkipsModelValidations
65
- end
66
- puts 'Missing Insights facets created'
41
+ organization_ids.each do |organization_id|
42
+ ForemanTasks.sync_task(
43
+ ForemanInventoryUpload::Async::HostInventoryReportJob,
44
+ base_folder,
45
+ organization_id,
46
+ filter,
47
+ false # don't upload; the user ran report:generate and not report:generate_upload
48
+ )
67
49
  end
50
+ puts "Check the Uploading tab for report uploading status." if Setting[:subscription_connection_enabled]
68
51
  end
69
52
  end
70
53
  desc 'Upload generated inventory report to Red Hat cloud'
@@ -72,8 +55,7 @@ namespace :rh_cloud_inventory do
72
55
  base_folder = ENV['target'] || ForemanInventoryUpload.generated_reports_folder
73
56
  organization_id = ENV['organization_id']
74
57
  report_file = ForemanInventoryUpload.facts_archive_name(organization_id)
75
- disconnected = ForemanRhCloud.with_iop_smart_proxy?
76
- ForemanTasks.sync_task(ForemanInventoryUpload::Async::QueueForUploadJob, base_folder, report_file, organization_id, disconnected)
58
+ ForemanTasks.sync_task(ForemanInventoryUpload::Async::QueueForUploadJob, base_folder, report_file, organization_id)
77
59
  puts "Uploaded #{report_file}"
78
60
  end
79
61
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foreman_rh_cloud",
3
- "version": "13.0.4",
3
+ "version": "13.0.5",
4
4
  "description": "Inventory Upload =============",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -313,4 +313,51 @@ class FactHelpersTest < ActiveSupport::TestCase
313
313
  assert_equal '10.230.230.3', ip3
314
314
  end
315
315
  end
316
+
317
+ describe 'IoP smart proxy checks' do
318
+ test 'obfuscate_hostname? returns false when global setting is enabled but IoP is present' do
319
+ Setting.expects(:[]).with(:obfuscate_inventory_hostnames).returns(true)
320
+ ForemanRhCloud.expects(:with_iop_smart_proxy?).returns(true)
321
+ host = mock('host')
322
+ # When IoP is present, it falls back to checking host-specific facts
323
+ @instance.expects(:fact_value).with(host, 'insights_client::obfuscate_hostname_enabled').returns(nil)
324
+
325
+ result = @instance.obfuscate_hostname?(host)
326
+
327
+ refute result
328
+ end
329
+
330
+ test 'obfuscate_hostname? returns true when global setting is enabled and IoP is not present' do
331
+ Setting.expects(:[]).with(:obfuscate_inventory_hostnames).returns(true)
332
+ ForemanRhCloud.expects(:with_iop_smart_proxy?).returns(false)
333
+ host = mock('host')
334
+
335
+ result = @instance.obfuscate_hostname?(host)
336
+
337
+ assert result
338
+ end
339
+
340
+ test 'obfuscate_ips? returns false when global setting is enabled but IoP is present' do
341
+ Setting.expects(:[]).with(:obfuscate_inventory_ips).returns(true)
342
+ ForemanRhCloud.expects(:with_iop_smart_proxy?).returns(true)
343
+ host = mock('host')
344
+ # When IoP is present, it falls back to checking host-specific facts
345
+ @instance.expects(:fact_value).with(host, 'insights_client::obfuscate_ipv4_enabled').returns(nil)
346
+ @instance.expects(:fact_value).with(host, 'insights_client::obfuscate_ipv6_enabled').returns(nil)
347
+
348
+ result = @instance.obfuscate_ips?(host)
349
+
350
+ refute result
351
+ end
352
+
353
+ test 'obfuscate_ips? returns true when global setting is enabled and IoP is not present' do
354
+ Setting.expects(:[]).with(:obfuscate_inventory_ips).returns(true)
355
+ ForemanRhCloud.expects(:with_iop_smart_proxy?).returns(false)
356
+ host = mock('host')
357
+
358
+ result = @instance.obfuscate_ips?(host)
359
+
360
+ assert result
361
+ end
362
+ end
316
363
  end
@@ -5,7 +5,16 @@ class VmaasReposcanSyncTest < ActiveSupport::TestCase
5
5
  include ForemanTasks::TestHelpers::WithInThreadExecutor
6
6
 
7
7
  setup do
8
- @repo_payload = { id: 123 }
8
+ @root = FactoryBot.build(:katello_root_repository, :fedora_17_x86_64_dev_root)
9
+ @root.save(validate: false)
10
+ @repo = FactoryBot.create(
11
+ :katello_repository,
12
+ :with_product,
13
+ distribution_family: 'Red Hat',
14
+ distribution_version: '7.5',
15
+ root: @root
16
+ )
17
+ @repo_payload = { id: @repo.id }
9
18
  @expected_url = 'https://example.com/api/v1/vmaas/reposcan/sync'
10
19
  InsightsCloud.stubs(:vmaas_reposcan_sync_url).returns(@expected_url)
11
20
  ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(true)
@@ -68,7 +77,8 @@ class VmaasReposcanSyncTest < ActiveSupport::TestCase
68
77
  params[:method] == :put &&
69
78
  params[:url] == @expected_url &&
70
79
  params[:headers].is_a?(Hash) &&
71
- params[:headers]['Content-Type'] == 'application/json'
80
+ params[:headers]['Content-Type'] == 'application/json' &&
81
+ params[:organization] == @repo.organization
72
82
  end
73
83
  .returns(mock_response)
74
84
 
@@ -107,6 +107,36 @@ class SliceGeneratorTest < ActiveSupport::TestCase
107
107
  assert_equal 'test_nic1', actual_nic['name']
108
108
  end
109
109
 
110
+ test 'does not generate a report with minimal data collection when iop is present' do
111
+ Setting[:insights_minimal_data_collection] = true
112
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(true)
113
+
114
+ batch = Host.where(id: @host.id).in_batches.first
115
+ generator = create_generator(batch)
116
+
117
+ json_str = generator.render
118
+ actual = JSON.parse(json_str.join("\n"))
119
+
120
+ assert_equal '00000000-0000-0000-0000-000000000000', actual['report_slice_id']
121
+ assert_not_nil(actual_host = actual['hosts'].first)
122
+ assert_nil actual_host['ip_addresses']
123
+ assert_nil actual_host['mac_addresses']
124
+ assert_equal @host.fqdn, actual_host['fqdn']
125
+ assert_equal '1234', actual_host['account']
126
+ assert_equal 1, generator.hosts_count
127
+ assert_not_nil(actual_system_profile = actual_host['system_profile'])
128
+ assert_nil actual_system_profile['number_of_cpus']
129
+ assert_nil actual_system_profile['number_of_sockets']
130
+ assert_nil actual_system_profile['cores_per_socket']
131
+ assert_nil actual_system_profile['system_memory_bytes']
132
+ assert_nil actual_system_profile['os_release']
133
+ assert_not_nil(actual_network_interfaces = actual_system_profile['network_interfaces'])
134
+ assert_not_nil(actual_nic = actual_network_interfaces.first)
135
+ refute actual_nic.key?('mtu')
136
+ refute actual_nic.key?('mac_address')
137
+ assert_equal 'test_nic1', actual_nic['name']
138
+ end
139
+
110
140
  test 'generates a report with minimal data collection' do
111
141
  Setting[:insights_minimal_data_collection] = true
112
142
  create_fact_values(@host,
@@ -930,6 +960,33 @@ class SliceGeneratorTest < ActiveSupport::TestCase
930
960
  assert_equal 'alibaba', actual_profile['cloud_provider']
931
961
  end
932
962
 
963
+ test 'do not exclude packages when iop is present' do
964
+ Setting[:exclude_installed_packages] = true
965
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(true)
966
+ installed_package = ::Katello::InstalledPackage.create(name: 'test-package', nvrea: 'test-package-1.0.x86_64', nvra: 'test-package-1.0.x86_64')
967
+
968
+ another_host = FactoryBot.create(
969
+ :host,
970
+ :with_subscription,
971
+ :with_content,
972
+ content_view: @host.content_views.first,
973
+ lifecycle_environment: @host.lifecycle_environments.first,
974
+ organization: @host.organization,
975
+ installed_packages: [installed_package]
976
+ )
977
+
978
+ batch = Host.where(id: another_host.id).in_batches.first
979
+ generator = create_generator(batch)
980
+
981
+ json_str = generator.render
982
+ actual = JSON.parse(json_str.join("\n"))
983
+
984
+ assert_equal '00000000-0000-0000-0000-000000000000', actual['report_slice_id']
985
+ assert_not_nil(actual_host = actual['hosts'].first)
986
+ assert_not_nil(actual_profile = actual_host['system_profile'])
987
+ assert_not_nil(actual_profile['installed_packages'])
988
+ end
989
+
933
990
  test 'include packages installed in the report' do
934
991
  Setting[:exclude_installed_packages] = false
935
992
  installed_package = ::Katello::InstalledPackage.create(name: 'test-package', nvrea: 'test-package-1.0.x86_64', nvra: 'test-package-1.0.x86_64')
@@ -18,36 +18,67 @@ import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
18
18
  import { insightsCloudUrl } from '../InsightsCloudSync/InsightsCloudSyncHelpers';
19
19
  import { getInitialRisks, theme } from './InsightsTabConstants';
20
20
 
21
- const InsightsTotalRiskCard = ({ hostDetails: { id } }) => {
21
+ const InsightsTotalRiskCard = ({ hostDetails }) => {
22
+ const { id, insights_attributes: insightsFacet } = hostDetails;
23
+ const uuid = insightsFacet?.uuid;
24
+ // eslint-disable-next-line camelcase
25
+ const isIop = insightsFacet?.use_iop_mode;
22
26
  const [totalRisks, setTotalRisks] = useState(getInitialRisks());
23
27
  const hashHistory = useHistory();
24
28
  const dispatch = useDispatch();
25
29
  const API_KEY = `HOST_${id}_RECOMMENDATIONS`;
26
30
  const API_OPTIONS = useMemo(() => ({ key: API_KEY }), [API_KEY]);
27
- const url = id && insightsCloudUrl(`hits/${id}`); // This will keep the API call from being triggered if there's no host id.
28
- const {
29
- status = STATUS.PENDING,
30
- response: { hits = [] },
31
- } = useAPI('get', url, API_OPTIONS);
32
31
 
33
- useEffect(() => {
34
- if (status === STATUS.RESOLVED) {
35
- const risks = getInitialRisks();
32
+ // This will keep the API call from being triggered if there's no host id.
33
+ const url = isIop
34
+ ? uuid && insightsCloudUrl(`api/insights/v1/system/${uuid}`)
35
+ : id && insightsCloudUrl(`hits/${id}`);
36
+ const { status = STATUS.PENDING, response } = useAPI('get', url, API_OPTIONS);
37
+
38
+ const checkRisks = useMemo(() => {
39
+ if (!response || status !== STATUS.RESOLVED) {
40
+ return getInitialRisks();
41
+ }
42
+
43
+ const risks = getInitialRisks();
44
+ if (isIop) {
45
+ const {
46
+ low_hits: lowHits = 0,
47
+ moderate_hits: moderateHits = 0,
48
+ important_hits: importantHits = 0,
49
+ critical_hits: criticalHits = 0,
50
+ hits = 0,
51
+ } = response;
52
+
53
+ risks[1].value += lowHits;
54
+ risks[2].value += moderateHits;
55
+ risks[3].value += importantHits;
56
+ risks[4].value += criticalHits;
57
+ risks.total = hits;
58
+ } else {
59
+ const { hits = [] } = response;
36
60
  hits.forEach(({ total_risk: risk }) => {
37
61
  risks[risk].value += 1;
38
62
  });
39
63
  risks.total = hits.length;
40
- setTotalRisks(risks);
41
64
  }
42
- }, [hits, status]);
65
+ return risks;
66
+ }, [response, status, isIop]);
67
+
68
+ useEffect(() => {
69
+ setTotalRisks(checkRisks);
70
+ }, [checkRisks]);
71
+
72
+ if (!insightsFacet) return null;
43
73
 
44
74
  const onChartClick = (evt, { index }) => {
45
75
  hashHistory.push(`/Insights`);
46
- dispatch(
47
- push({
48
- search: `search=total_risk+%3D+${index + 1}`,
49
- })
50
- );
76
+ !isIop &&
77
+ dispatch(
78
+ push({
79
+ search: `search=total_risk+%3D+${index + 1}`,
80
+ })
81
+ );
51
82
  };
52
83
 
53
84
  const onChartHover = (evt, { index }) => [
@@ -61,11 +92,16 @@ const InsightsTotalRiskCard = ({ hostDetails: { id } }) => {
61
92
  const { 1: low, 2: moderate, 3: important, 4: critical, total } = totalRisks;
62
93
 
63
94
  // eslint-disable-next-line react/prop-types
64
- const LegendLabel = ({ index, ...rest }) => (
65
- <a key={index} onClick={() => onChartClick(null, { index })}>
66
- <ChartLabel {...rest} />
67
- </a>
68
- );
95
+ const LegendLabel = ({ index, ...rest }) => {
96
+ if (isIop) {
97
+ return <ChartLabel {...rest} />;
98
+ }
99
+ return (
100
+ <a key={index} onClick={() => onChartClick(null, { index })}>
101
+ <ChartLabel {...rest} />
102
+ </a>
103
+ );
104
+ };
69
105
 
70
106
  const legend = (
71
107
  <ChartLegend
@@ -0,0 +1,194 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { Provider } from 'react-redux';
5
+ import { ConnectedRouter } from 'connected-react-router';
6
+ import { createMemoryHistory } from 'history';
7
+ import configureMockStore from 'redux-mock-store';
8
+ import { STATUS } from 'foremanReact/constants';
9
+ import * as APIHooks from 'foremanReact/common/hooks/API/APIHooks';
10
+ import InsightsTotalRiskCard from '../InsightsTotalRiskChart';
11
+
12
+ jest.mock('foremanReact/common/hooks/API/APIHooks');
13
+ jest.mock('foremanReact/common/I18n', () => ({
14
+ translate: jest.fn(str => str),
15
+ }));
16
+
17
+ const mockStore = configureMockStore();
18
+ const history = createMemoryHistory();
19
+ const store = mockStore({
20
+ router: {
21
+ location: {
22
+ pathname: '/',
23
+ search: '',
24
+ hash: '',
25
+ state: null,
26
+ },
27
+ action: 'POP',
28
+ },
29
+ });
30
+
31
+ const defaultHostDetails = {
32
+ id: 1,
33
+ insights_attributes: {
34
+ uuid: 'test-uuid',
35
+ use_iop_mode: false,
36
+ },
37
+ };
38
+
39
+ const renderComponent = (props = {}) => {
40
+ const allProps = {
41
+ hostDetails: defaultHostDetails,
42
+ ...props,
43
+ };
44
+
45
+ return render(
46
+ <Provider store={store}>
47
+ <ConnectedRouter history={history}>
48
+ <InsightsTotalRiskCard {...allProps} />
49
+ </ConnectedRouter>
50
+ </Provider>
51
+ );
52
+ };
53
+
54
+ describe('InsightsTotalRiskChart', () => {
55
+ beforeEach(() => {
56
+ store.clearActions();
57
+ jest.clearAllMocks();
58
+ });
59
+
60
+ it('should show loading state initially', () => {
61
+ APIHooks.useAPI.mockReturnValue({
62
+ status: STATUS.PENDING,
63
+ response: null,
64
+ });
65
+
66
+ renderComponent();
67
+ // SkeletonLoader shows loading state when status is PENDING
68
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
69
+ expect(
70
+ screen.queryByTestId('rh-cloud-total-risk-card')
71
+ ).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('should display error state when API fails', async () => {
75
+ APIHooks.useAPI.mockReturnValue({
76
+ status: STATUS.ERROR,
77
+ response: null,
78
+ });
79
+
80
+ renderComponent();
81
+ expect(screen.getByText('No results found')).toBeInTheDocument();
82
+ expect(
83
+ screen.queryByTestId('rh-cloud-total-risk-card')
84
+ ).not.toBeInTheDocument();
85
+ });
86
+
87
+ it('should handle non-IoP mode API response correctly', async () => {
88
+ const mockResponse = {
89
+ hits: [
90
+ { total_risk: 1 },
91
+ { total_risk: 2 },
92
+ { total_risk: 2 },
93
+ { total_risk: 3 },
94
+ { total_risk: 4 },
95
+ ],
96
+ };
97
+
98
+ APIHooks.useAPI.mockReturnValue({
99
+ status: STATUS.RESOLVED,
100
+ response: mockResponse,
101
+ });
102
+
103
+ renderComponent();
104
+
105
+ await waitFor(() => {
106
+ // Check if total number of recommendations is displayed
107
+ expect(screen.getByText('5')).toBeInTheDocument();
108
+ // Check if risk levels are displayed correctly
109
+ expect(screen.getByText(/Low: 1/)).toBeInTheDocument();
110
+ expect(screen.getByText(/Moderate: 2/)).toBeInTheDocument();
111
+ expect(screen.getByText(/Important: 1/)).toBeInTheDocument();
112
+ expect(screen.getByText(/Critical: 1/)).toBeInTheDocument();
113
+ });
114
+ });
115
+
116
+ it('should handle IOP mode API response correctly', async () => {
117
+ const mockResponse = {
118
+ low_hits: 2,
119
+ moderate_hits: 3,
120
+ important_hits: 1,
121
+ critical_hits: 2,
122
+ hits: 8,
123
+ };
124
+
125
+ APIHooks.useAPI.mockReturnValue({
126
+ status: STATUS.RESOLVED,
127
+ response: mockResponse,
128
+ });
129
+
130
+ renderComponent({
131
+ hostDetails: {
132
+ ...defaultHostDetails,
133
+ insights_attributes: {
134
+ ...defaultHostDetails.insights_attributes,
135
+ use_iop_mode: true,
136
+ },
137
+ },
138
+ });
139
+
140
+ await waitFor(() => {
141
+ // Check if total number of recommendations is displayed
142
+ expect(screen.getByText('8')).toBeInTheDocument();
143
+ // Check if risk levels are displayed correctly
144
+ expect(screen.getByText(/Low: 2/)).toBeInTheDocument();
145
+ expect(screen.getByText(/Moderate: 3/)).toBeInTheDocument();
146
+ expect(screen.getByText(/Important: 1/)).toBeInTheDocument();
147
+ expect(screen.getByText(/Critical: 2/)).toBeInTheDocument();
148
+ });
149
+ });
150
+
151
+ it('should show empty state when no recommendations exist', async () => {
152
+ APIHooks.useAPI.mockReturnValue({
153
+ status: STATUS.RESOLVED,
154
+ response: { hits: [] },
155
+ });
156
+
157
+ renderComponent();
158
+
159
+ await waitFor(() => {
160
+ expect(screen.getByText(/Low: 0/)).toBeInTheDocument();
161
+ expect(screen.getByText(/Moderate: 0/)).toBeInTheDocument();
162
+ expect(screen.getByText(/Important: 0/)).toBeInTheDocument();
163
+ expect(screen.getByText(/Critical: 0/)).toBeInTheDocument();
164
+ });
165
+ });
166
+
167
+ it('should use correct API endpoint based on IOP mode', () => {
168
+ renderComponent({
169
+ hostDetails: {
170
+ ...defaultHostDetails,
171
+ insights_attributes: {
172
+ ...defaultHostDetails.insights_attributes,
173
+ use_iop_mode: true,
174
+ },
175
+ },
176
+ });
177
+
178
+ expect(APIHooks.useAPI).toHaveBeenCalledWith(
179
+ 'get',
180
+ expect.stringContaining('/api/insights/v1/system/test-uuid'),
181
+ expect.any(Object)
182
+ );
183
+
184
+ jest.clearAllMocks();
185
+
186
+ renderComponent();
187
+
188
+ expect(APIHooks.useAPI).toHaveBeenCalledWith(
189
+ 'get',
190
+ expect.stringContaining('/hits/1'),
191
+ expect.any(Object)
192
+ );
193
+ });
194
+ });
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_rh_cloud
3
3
  version: !ruby/object:Gem::Version
4
- version: 13.0.4
4
+ version: 13.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Red Hat Cloud team
@@ -139,7 +139,6 @@ files:
139
139
  - app/services/foreman_rh_cloud/cloud_presence.rb
140
140
  - app/services/foreman_rh_cloud/cloud_request.rb
141
141
  - app/services/foreman_rh_cloud/cloud_request_forwarder.rb
142
- - app/services/foreman_rh_cloud/gateway_request.rb
143
142
  - app/services/foreman_rh_cloud/hit_remediations_retriever.rb
144
143
  - app/services/foreman_rh_cloud/hits_uploader.rb
145
144
  - app/services/foreman_rh_cloud/insights_api_forwarder.rb
@@ -185,13 +184,17 @@ files:
185
184
  - db/seeds.d/50_job_templates.rb
186
185
  - lib/foreman_inventory_upload.rb
187
186
  - lib/foreman_inventory_upload/async/async_helpers.rb
187
+ - lib/foreman_inventory_upload/async/create_missing_insights_facets.rb
188
188
  - lib/foreman_inventory_upload/async/delayed_start.rb
189
189
  - lib/foreman_inventory_upload/async/generate_all_reports_job.rb
190
+ - lib/foreman_inventory_upload/async/generate_host_report.rb
190
191
  - lib/foreman_inventory_upload/async/generate_report_job.rb
192
+ - lib/foreman_inventory_upload/async/host_inventory_report_job.rb
191
193
  - lib/foreman_inventory_upload/async/progress_output.rb
192
194
  - lib/foreman_inventory_upload/async/queue_for_upload_job.rb
193
195
  - lib/foreman_inventory_upload/async/remove_insights_hosts_job.rb
194
196
  - lib/foreman_inventory_upload/async/shell_process.rb
197
+ - lib/foreman_inventory_upload/async/single_host_report_job.rb
195
198
  - lib/foreman_inventory_upload/async/upload_report_job.rb
196
199
  - lib/foreman_inventory_upload/generators/archived_report.rb
197
200
  - lib/foreman_inventory_upload/generators/fact_helpers.rb
@@ -621,6 +624,7 @@ files:
621
624
  - webpack/InsightsHostDetailsTab/__tests__/InsightsTabIntegration.test.js
622
625
  - webpack/InsightsHostDetailsTab/__tests__/InsightsTabReducer.test.js
623
626
  - webpack/InsightsHostDetailsTab/__tests__/InsightsTabSelectors.test.js
627
+ - webpack/InsightsHostDetailsTab/__tests__/InsightsTotalRiskChart.test.js
624
628
  - webpack/InsightsHostDetailsTab/__tests__/__snapshots__/InsightsTab.test.js.snap
625
629
  - webpack/InsightsHostDetailsTab/__tests__/__snapshots__/InsightsTabActions.test.js.snap
626
630
  - webpack/InsightsHostDetailsTab/__tests__/__snapshots__/InsightsTabReducer.test.js.snap
@@ -1,26 +0,0 @@
1
- module ForemanRhCloud
2
- module GatewayRequest
3
- extend ActiveSupport::Concern
4
-
5
- include CloudRequest
6
-
7
- def execute_cloud_request(params)
8
- certs = params.delete(:certs) || foreman_certificates
9
- final_params = {
10
- ssl_client_cert: OpenSSL::X509::Certificate.new(certs[:cert]),
11
- ssl_client_key: OpenSSL::PKey.read(certs[:key]),
12
- ssl_ca_file: Setting[:ssl_ca_file],
13
- verify_ssl: OpenSSL::SSL::VERIFY_PEER,
14
- }.deep_merge(params)
15
-
16
- super(final_params)
17
- end
18
-
19
- def foreman_certificates
20
- {
21
- cert: File.read(Setting[:ssl_certificate]),
22
- key: File.read(Setting[:ssl_priv_key]),
23
- }
24
- end
25
- end
26
- end