diff --git a/.env.example b/.env.example index 2229a9035..68abdc7b0 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,5 @@ SECRET_KEY_BASE=8ea53ad3bc6c03923e376c8bdd85059c1885524947a7efe53d5e9c9d4e398611 EXTERNAL_PORT=8080 SCC_USERNAME= SCC_PASSWORD= +RMT_METRICS_ENABLED= +PROMETHEUS_JOB_NAME= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0af224699..e1b09f1cd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,14 @@ ## Description -Please describe your change and provide as much context as possible. + -Fixes # (issue) +* Related Issue / Ticket / Trello card: + +## How to test + + ## Change Type @@ -16,13 +22,13 @@ Fixes # (issue) *Please check off each item if the requirement is met.* -- [ ] I have verified that my code follows RMT's coding standards with `rubocop`. - [ ] I have reviewed my own code and believe that it's ready for an external review. - [ ] I have provided comments for any hard-to-understand code. - [ ] I have documented the `MANUAL.md` file with any changes to the user experience. -- [ ] RMT's test coverage remains at 100%. - [ ] If my changes are non-trivial, I have added a changelog entry to notify users at `package/obs/rmt-server.changes`. -## Other Notes +## Review + +Please check out our [review guidelines](https://github.com/SUSE/scc-docs/blob/master/team/workflow/code_review.md) +and get in touch with the author to get a shared understanding of the change. -Please use this space to provide notes or thoughts to the team, such as tips on how to review/demo your changes. diff --git a/.github/workflows/lint-unit.yml b/.github/workflows/lint-unit.yml index 66bf3f77c..e97009ec4 100644 --- a/.github/workflows/lint-unit.yml +++ b/.github/workflows/lint-unit.yml @@ -77,17 +77,17 @@ jobs: - name: Prepare database run: | - bundle exec rails db:migrate + bundle exec rails db:drop db:create db:migrate - name: Run core tests run: | - bundle exec rspec --format documentation + bundle exec rake test:core - name: Run core tests with sqlite run: | sed -i 's/adapter: mysql2/adapter: sqlite3/' config/rmt.yml - bundle exec rspec --format documentation - + bundle exec rake test:core + - name: Run PubCloud engines tests run: | bundle exec rake test:engines @@ -96,4 +96,4 @@ jobs: run: | echo "::group::Version verification checks" ruby ci/check-version-matches.rb - echo "::endgroup::" + echo "::endgroup::" \ No newline at end of file diff --git a/.simplecov b/.simplecov index e08fb8ee6..052bcb2d8 100644 --- a/.simplecov +++ b/.simplecov @@ -10,6 +10,10 @@ unless ENV['NO_COVERAGE'] # omit registration sharing (removing systems using rmt-cli) add_filter('engines/registration_sharing/lib/registration_sharing.rb') + add_filter('lib/rmt/db.rb') + add_filter('lib/rmt.rb') + add_filter('config') + track_files('app/**/*.rb') track_files('lib/**/*.rb') end diff --git a/Gemfile b/Gemfile index 82f2f5ec0..d57185c60 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,12 @@ gem 'repomd_parser', '~> 1.1.0' # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible # gem 'rack-cors' +# Prometheus Exporter: +gem 'yabeda' +gem 'yabeda-rails' +gem 'yabeda-puma-plugin' +gem 'yabeda-prometheus' + gem 'strong_migrations' group :development, :test do @@ -43,7 +49,7 @@ group :development, :test do gem 'gettext', require: false # needed for gettext_i18n_rails tasks gem 'ruby_parser', '< 3.20', require: false # needed for gettext_i18n_rails tasks, Locked because of Ruby >= 2.6 dependency gem 'gettext_test_log' - gem 'memory_profiler' + gem 'memory_profiler', '~> 1.0.2' # locked because 1.1.0 requires ruby version >= 3.1.0 gem 'awesome_print' end diff --git a/Gemfile.lock b/Gemfile.lock index b0ee3b50a..1ca660f8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,13 +30,15 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + anyway_config (2.6.4) + ruby-next-core (~> 1.0) ast (2.4.2) awesome_print (1.9.2) base32 (0.3.4) base64 (0.2.0) - bigdecimal (3.1.6) + bigdecimal (3.1.8) builder (3.2.4) byebug (11.1.3) bzip2-ffi (1.1.1) @@ -104,7 +106,8 @@ GEM factory_bot (~> 6.2.0) railties (>= 5.0.0) fakefs (1.8.0) - fast_gettext (2.3.0) + fast_gettext (2.4.0) + prime ffaker (2.21.0) ffi (1.16.3) formatador (0.2.5) @@ -118,7 +121,7 @@ GEM prime racc text (>= 1.3.0) - gettext_i18n_rails (1.12.0) + gettext_i18n_rails (1.13.0) fast_gettext (>= 0.9.0) gettext_test_log (0.2.1) guard (2.16.2) @@ -135,13 +138,13 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) - hashdiff (1.1.0) + hashdiff (1.1.1) hpricot (0.8.6) i18n (1.14.5) concurrent-ruby (~> 1.0) json (2.3.1) jsonapi-renderer (0.2.2) - jwt (2.8.1) + jwt (2.9.3) base64 listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -151,7 +154,7 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) lumberjack (1.2.8) - memory_profiler (1.0.1) + memory_profiler (1.0.2) method_source (1.1.0) mini_portile2 (2.6.1) minitest (5.15.0) @@ -159,7 +162,7 @@ GEM mustache (1.1.1) mysql2 (0.5.6) nenv (0.3.0) - nio4r (2.7.0) + nio4r (2.7.3) nokogiri (1.12.5) mini_portile2 (~> 2.6.1) racc (~> 1.4) @@ -172,11 +175,13 @@ GEM prime (0.1.2) forwardable singleton + prometheus-client (4.2.3) + base64 pry (0.14.0) coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.7) - puma (5.6.8) + puma (5.6.9) nio4r (~> 2.0) racc (1.8.0) rack (2.2.9) @@ -208,8 +213,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.8) - strscan (>= 3.0.9) + rexml (3.3.9) ronn (0.7.3) hpricot (>= 0.8.2) mustache (>= 0.7.0) @@ -266,6 +270,7 @@ GEM rubocop (~> 1.19) rubocop-thread_safety (0.4.4) rubocop (>= 0.53.0) + ruby-next-core (1.0.3) ruby-progressbar (1.11.0) ruby_parser (3.19.2) sexp_processor (~> 4.16) @@ -295,7 +300,6 @@ GEM sqlite3 (1.4.4) strong_migrations (0.7.9) activerecord (>= 5) - strscan (3.1.0) sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) @@ -303,7 +307,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) text (1.3.1) thor (1.2.2) - timecop (0.9.9) + timecop (0.9.10) tins (1.26.0) sync typhoeus (1.4.1) @@ -316,10 +320,27 @@ GEM activesupport (>= 3) railties (>= 3) yard (~> 0.9.20) - webmock (3.23.0) + webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + yabeda (0.13.1) + anyway_config (>= 1.0, < 3) + concurrent-ruby + dry-initializer + yabeda-prometheus (0.9.1) + prometheus-client (>= 3.0, < 5.0) + rack + yabeda (~> 0.10) + yabeda-puma-plugin (0.7.1) + json + puma + yabeda (~> 0.5) + yabeda-rails (0.9.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) yard (0.9.35) zeitwerk (2.6.15) zstd-ruby (1.5.6.1) @@ -350,7 +371,7 @@ DEPENDENCIES guard-rspec jwt (~> 2.1) listen (>= 3.0.5, <= 3.6.0) - memory_profiler + memory_profiler (~> 1.0.2) minitest (~> 5.15.0) mysql2 (~> 0.5.3) nokogiri (< 1.13) @@ -381,6 +402,10 @@ DEPENDENCIES vcr (~> 6.0) versionist webmock + yabeda + yabeda-prometheus + yabeda-puma-plugin + yabeda-rails BUNDLED WITH 1.17.3 diff --git a/PACKAGE.md b/PACKAGE.md index 7412e8e59..557f750d0 100644 --- a/PACKAGE.md +++ b/PACKAGE.md @@ -21,7 +21,7 @@ Note: Look below for direction on publishing to registry. ``` * Alternatively, if an OBS working copy is already checked out, update the working copy by running `osc up` 2. Run `make dist` in your RMT working directory to build a tarball. -3. Copy the files from the `package/obs` directory to the OBS working directory. +3. Copy the files from the `package/obs` directory to the OBS working directory `systemsmanagement:SCC:RMT/rmt-server`. 4. Examine the changes by running `osc status` and `osc diff`. 5. Stage the changes by running `osc addremove`. 6. Build the package with osc: @@ -41,14 +41,6 @@ Note: Look below for direction on publishing to registry. ``` 2. On github, submit a release for the tag. See https://help.github.com/en/articles/creating-releases for assistance. -#### Helm chart update process - -RMT helm chart is found [here](https://github.com/SUSE/helm-charts.git). - -Edit `rmt-helm/Chart.yaml` to update the chart version (`version`) and rmt-version (`appVersion`). The `BuildTag` version needs to be updated. Look at this example [pull-request](https://github.com/SUSE/helm-charts/pull/5) bumping the version. - -[Bruno Leon](mailto:bruno.leon@suse.com) is the maintainer and point of contact for rmt-helm. - #### Submit Requests to openSUSE Factory and SLES To get a maintenance request accepted, each changelog entry needs to have at @@ -72,13 +64,7 @@ osc sr systemsmanagement:SCC:RMT rmt-server openSUSE:Factory ###### Get target codestreams where to submit -To check out which codestreams the package is currently maintained in, run: - -```bash -osc -A https://api.suse.de maintained rmt-server -``` - -For a more detailed view which target codestreams are in which state, check out: [Codestream overview](https://maintenance.suse.de/maintained/?package=rmt-server) +To check out which codestreams RMT is currently maintained, see https://smelt.suse.de/maintained/?q=rmt-server. ###### Submit updates @@ -112,8 +98,15 @@ You can check the status of your requests [here](https://build.opensuse.org/pack After your requests have been accepted, they still have to pass maintenance testing before they are released to customers. You can check their progress at [maintenance.suse.de](https://maintenance.suse.de/search/?q=rmt-server). If you still need help, the maintenance team can be reached at [maint-coord@suse.de](maint-coord@suse.de) or #maintenance on irc.suse.de. -## Packaging and publishing to SUSE registry +## Container image and publishing to SUSE registry + +SUSE registry houses the rmt-server docker image. The image is built automatically on OBS/IBS, and can be found [here](https://build.opensuse.org/package/show/devel:BCI:SLE-15-SP6/rmt-server-image). It is getting published here: `registry.suse.com/suse/rmt-server`. + -SUSE registry houses the rmt-server docker image. The image is built on OBS/IBS, project for SLES 15sp4 based distributions can be found [here](https://build.opensuse.org/package/show/devel:BCI:SLE-15-SP4/rmt-server-image). +#### Helm chart update process + +RMT helm chart is defined [here](https://github.com/SUSE/helm-charts.git) and published at `registry.suse.com/suse/rmt-helm`. + +Edit `rmt-helm/Chart.yaml` to update the chart version (`version`) and rmt-version (`appVersion`). The `BuildTag` version needs to be updated. Look at this example [pull-request](https://github.com/SUSE/helm-charts/pull/5) bumping the version. -At the moment of writing, the publishing process has to be done manually. This can be achieved by reaching out to the Auto-Build team (only available internally). +Reach out to the BCI team (Dirk Mueller or `#proj-bci` slack channel) to trigger the release of the helm-chart. diff --git a/app/controllers/api/connect/v3/systems/products_controller.rb b/app/controllers/api/connect/v3/systems/products_controller.rb index b985a5d48..5688cc9b4 100644 --- a/app/controllers/api/connect/v3/systems/products_controller.rb +++ b/app/controllers/api/connect/v3/systems/products_controller.rb @@ -12,7 +12,13 @@ def activate end def show - if @system.products.include? @product + if @product.identifier.casecmp?('sles') + # if system has SLE Micro + # it should access to SLES products + sle_micro = @system.products.any? { |p| p.identifier.downcase.include?('sle-micro') } + sle_micro_same_arch = @system.products.pluck(:arch).include?(@product.arch) if sle_micro + end + if @system.products.include?(@product) || sle_micro_same_arch respond_with( @product, serializer: ::V3::ProductSerializer, @@ -108,7 +114,7 @@ def create_product_activation # in BYOS mode, we rely on the activation being performed in # `engines/scc_proxy/lib/scc_proxy/engine.rb` and don't need further checks here - return activation if @system.proxy_byos + return activation if @system.byos? if @subscription.present? activation.subscription = @subscription @@ -123,8 +129,10 @@ def remove_previous_product_activations(product_ids) end def load_subscription - # Find subscription by regcode if provided, otherwise use the first subscription (bsc#1220109) - if params[:token].present? && !@system.proxy_byos + # Find subscription by regcode if provided and not a public cloud system, + # otherwise check if there's already an activation with a matching + # subscription (for migrations, bsc#1220109) + if params[:token].present? && @system.not_applicable? @subscription = Subscription.find_by(regcode: params[:token]) unless @subscription raise ActionController::TranslatedError.new(N_('No subscription with this Registration Code found')) diff --git a/app/controllers/api/connect/v3/systems/systems_controller.rb b/app/controllers/api/connect/v3/systems/systems_controller.rb index b7622e252..863557279 100644 --- a/app/controllers/api/connect/v3/systems/systems_controller.rb +++ b/app/controllers/api/connect/v3/systems/systems_controller.rb @@ -21,8 +21,8 @@ def update # Since the payload is handled by rails all values are converted to string # e.g. cpus: 16 becomes cpus: "16". We save this as string for now and expect - # SCC to handle the convertation correctly - @system.system_information = hwinfo_params[:hwinfo].to_json + # SCC to handle the conversion correctly + @system.system_information = @system.system_information_hash.update(hwinfo_params[:hwinfo]).to_json if @system.save logger.info(N_("Updated system information for host '%s'") % @system.hostname) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 12eaadef0..686972895 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,6 +18,7 @@ def authenticate_system(skip_on_duplicated: false) return true if skip_on_duplicated && @systems.size > 1 @system = find_system_by_token_header(@systems) + update_user_agent # If SYSTEM_TOKEN_HEADER is present, RMT assumes the client uses a SUSEConnect version # that supports this feature. In this case, refresh the token and include it in the response. @@ -42,6 +43,15 @@ def authenticate_system(skip_on_duplicated: false) private + def zypper_request? + user_agent = request.headers['HTTP_USER_AGENT'] + user_agent&.downcase&.starts_with?('zypp') + end + + def update_user_agent + @system.set_system_information('user_agent', request.headers['HTTP_USER_AGENT']) unless zypper_request? + end + # Token mechanism to detect duplicated systems. # 1: system doesn't send a token header (old SUSEConnect version) # 2: system sends a token, and it matches an existing system with that token diff --git a/app/controllers/services_controller.rb b/app/controllers/services_controller.rb index e28d94cbe..74f6d2f65 100644 --- a/app/controllers/services_controller.rb +++ b/app/controllers/services_controller.rb @@ -14,12 +14,10 @@ class ServicesController < ApplicationController # authenticate requests on this method for Zypper so we have a better picture # which systems are still being active (even if not using SUSEConnect). before_action only: %w[show] do - ua = request.headers['HTTP_USER_AGENT'] - # Zypper will never provide the `system_token` credentials for the system. # Hence, if there are duplicates, we will not be able to deterministically # tell which system is to be updated. Just skip it altogether on this case. - authenticate_system(skip_on_duplicated: true) if ua && ua.downcase.starts_with?('zypp') + authenticate_system(skip_on_duplicated: true) if zypper_request? end ZYPPER_SERVICE_TTL = 86400 diff --git a/app/models/system.rb b/app/models/system.rb index 0bfa987d4..2d9f97808 100644 --- a/app/models/system.rb +++ b/app/models/system.rb @@ -1,4 +1,11 @@ class System < ApplicationRecord + # This value has meaning/relevance only used in public cloud scenarios + # This value indicates that the system is using + # NOT_APPLICABLE (systems outside the public cloud), + # PAYG (pay as you go), + # BYOS (bring your own subscription) or + # a mix of both (hybrid). + enum proxy_byos_mode: { not_applicable: 0, payg: 1, byos: 2, hybrid: 3 } after_initialize :init @@ -30,10 +37,17 @@ def self.generate_secure_login end def cloud_provider + system_information_hash.fetch(:cloud_provider, nil) + end + + def system_information_hash # system_information is checked for valid JSON on save. It is safe # to assume the structure is valid. - info = JSON.parse(system_information).symbolize_keys - info.fetch(:cloud_provider, nil) + JSON.parse(system_information || '{}').symbolize_keys + end + + def set_system_information(key, value) + update(system_information: system_information_hash.update(key => value).to_json) end # Generate secure token for System password diff --git a/app/serializers/v3/product_serializer.rb b/app/serializers/v3/product_serializer.rb index d0ccea8b4..90747f838 100644 --- a/app/serializers/v3/product_serializer.rb +++ b/app/serializers/v3/product_serializer.rb @@ -33,8 +33,11 @@ def eula_url end def free - # Everything is free on RMT :-) + # Everything is free on RMT :-) outside of the Public Cloud (i.e. LTSS) # Otherwise Yast and SUSEConnect will request a regcode when activating an extension + # FIXME + return object.free if defined?(SccProxy::Engine) && object.extension? + true end diff --git a/bin/rmt-cli b/bin/rmt-cli index b71c9ceb6..1e649f8b8 100755 --- a/bin/rmt-cli +++ b/bin/rmt-cli @@ -42,6 +42,12 @@ if Settings.try(:host_system).blank? && File.exist?(RMT::CREDENTIALS_FILE_LOCATI warn "Run as root or adjust the permissions." end +unless Settings['database'] + warn "Error loading database config." + warn 'Please make sure that /etc/rmt.conf is readable by the rmt process and has your database configured.' + exit RMT::CLI::Error::ERROR_OTHER +end + db_config = RMT::Config.db_config ActiveRecord::Base.establish_connection(db_config) diff --git a/config/initializers/yabeda.rb b/config/initializers/yabeda.rb new file mode 100644 index 000000000..0396c27e8 --- /dev/null +++ b/config/initializers/yabeda.rb @@ -0,0 +1,38 @@ +# :nocov: +# frozen_string_literal: true + +return unless Settings.dig(:scc, :metrics, :enabled) + +# Configure prometheus client +Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new( + dir: './tmp/prometheus/' +) + +# Configure yabeda +Yabeda.configure do + assign_labels = lambda { + default_tag :environment, Rails.env + default_tag :application, 'rmt' + } + + group :rails, &assign_labels + + group :rails do + counter :started_requests_total, + comment: 'A counter of the total number of HTTP requests rails has started to process.', + tags: %i[controller action format method] + end +end + +# Instrument the request from the start +ActiveSupport::Notifications.subscribe 'start_processing.action_controller' do |*args| + # Match the same event as Yabeda + event = Yabeda::Rails::Event.new(*args) + + Yabeda.rails.started_requests_total.tap do |metric| + labels = event.labels.slice(*metric.tags) + + metric.increment(labels, by: 1) + end +end +# :nocov: diff --git a/config/puma.rb b/config/puma.rb index c1fec1414..39ace4d2b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,4 +1,5 @@ require 'rmt/config' +require_relative 'puma/prometheus' # Puma can serve each request in a thread from an internal thread pool. # The `threads` method setting takes two numbers: a minimum and maximum. @@ -36,3 +37,7 @@ # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart + +if Settings.dig(:scc, :metrics, :enabled) + configure_prometheus!(self) +end diff --git a/config/puma/prometheus.rb b/config/puma/prometheus.rb new file mode 100644 index 000000000..df5dcd9b9 --- /dev/null +++ b/config/puma/prometheus.rb @@ -0,0 +1,7 @@ +def configure_prometheus!(puma) + ENV['STARTED_FROM_PUMA'] = '1' + + puma.activate_control_app + puma.plugin :yabeda + puma.plugin :yabeda_prometheus +end diff --git a/config/rmt.yml b/config/rmt.yml index 3b1dabb2f..811e44ff2 100644 --- a/config/rmt.yml +++ b/config/rmt.yml @@ -24,6 +24,9 @@ scc: username: <%= ENV['SCC_USERNAME'] %> password: <%= ENV['SCC_PASSWORD'] %> sync_systems: true + metrics: + enabled: <%= ENV.fetch('RMT_METRICS_ENABLED') { false } %> + job_name: <%= ENV.fetch('PROMETHEUS_JOB_NAME') { 'rmt-webserver' } %> mirroring: mirror_src: false diff --git a/config/routes.rb b/config/routes.rb index 9cea050f2..7c43d4b87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -69,6 +69,12 @@ get '/v2/_catalog', to: 'registry/registry#catalog' end + # For bin/rails s users: expose the metrics endpoint via rails instead of puma + if Rails.env.development? && ENV['STARTED_FROM_PUMA'].blank? + Rails.logger.info('Mounting Yabeda in Rails.routes') + mount Yabeda::Prometheus::Exporter, at: '/metrics' + end + if defined?(SccSumaApi::Engine) mount SccSumaApi::Engine, at: '/api/scc' diff --git a/db/migrate/20240711154147_add_index_to_systems_system_token.rb b/db/migrate/20240711154147_add_index_to_systems_system_token.rb new file mode 100644 index 000000000..27b56ad24 --- /dev/null +++ b/db/migrate/20240711154147_add_index_to_systems_system_token.rb @@ -0,0 +1,5 @@ +class AddIndexToSystemsSystemToken < ActiveRecord::Migration[6.1] + def change + add_index :systems, %i[system_token] + end +end diff --git a/db/migrate/20240729103525_update_proxy_byos_column_type.rb b/db/migrate/20240729103525_update_proxy_byos_column_type.rb new file mode 100644 index 000000000..50cbf85fd --- /dev/null +++ b/db/migrate/20240729103525_update_proxy_byos_column_type.rb @@ -0,0 +1,11 @@ +class UpdateProxyByosColumnType < ActiveRecord::Migration[6.1] + def up + add_column :systems, :proxy_byos_mode, :integer, default: 0 + System.where(proxy_byos: false).where.not(instance_data: nil).in_batches.update_all proxy_byos_mode: :payg + System.where(proxy_byos: true).in_batches.update_all proxy_byos_mode: :byos + end + + def down + remove_column :systems, :proxy_byos_mode + end +end diff --git a/db/migrate/20240821114908_change_local_path_type.rb b/db/migrate/20240821114908_change_local_path_type.rb new file mode 100644 index 000000000..5c2f53cce --- /dev/null +++ b/db/migrate/20240821114908_change_local_path_type.rb @@ -0,0 +1,13 @@ +class ChangeLocalPathType < ActiveRecord::Migration[6.1] + def up + safety_assured do + change_column :repositories, :local_path, :string, limit: 512 + change_column :downloaded_files, :local_path, :string, limit: 512 + end + end + + def down + change_column :repositories, :local_path, :string + change_column :downloaded_files, :local_path, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f9f2a30a..1584f1814 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_01_29_140413) do +ActiveRecord::Schema.define(version: 2024_08_21_114908) do create_table "activations", charset: "utf8", force: :cascade do |t| t.bigint "service_id", null: false @@ -34,7 +34,7 @@ create_table "downloaded_files", charset: "utf8", force: :cascade do |t| t.string "checksum_type" t.string "checksum" - t.string "local_path" + t.string "local_path", limit: 512 t.bigint "file_size", unsigned: true t.index ["checksum_type", "checksum"], name: "index_downloaded_files_on_checksum_type_and_checksum" t.index ["local_path"], name: "index_downloaded_files_on_local_path", unique: true @@ -104,7 +104,7 @@ t.string "auth_token" t.boolean "installer_updates", default: false, null: false t.boolean "mirroring_enabled", default: false, null: false - t.string "local_path", null: false + t.string "local_path", limit: 512, null: false t.datetime "last_mirrored_at" t.string "friendly_id" t.index ["external_url"], name: "index_repositories_on_external_url", unique: true @@ -169,11 +169,13 @@ t.datetime "scc_registered_at" t.bigint "scc_system_id", comment: "System ID in SCC (if the system registration was forwarded; needed for forwarding de-registrations)" t.boolean "proxy_byos", default: false + t.integer "proxy_byos_mode", default: 0 t.string "system_token" t.text "system_information", size: :long t.text "instance_data" t.index ["login", "password", "system_token"], name: "index_systems_on_login_and_password_and_system_token", unique: true t.index ["login", "password"], name: "index_systems_on_login_and_password" + t.index ["system_token"], name: "index_systems_on_system_token" t.check_constraint "json_valid(`system_information`)", name: "system_information" end diff --git a/engines/instance_verification/lib/instance_verification/engine.rb b/engines/instance_verification/lib/instance_verification/engine.rb index 77d52c621..45438e3f0 100644 --- a/engines/instance_verification/lib/instance_verification/engine.rb +++ b/engines/instance_verification/lib/instance_verification/engine.rb @@ -58,11 +58,11 @@ def verify_product_activation if product.base? verify_base_product_activation(product) - else - verify_extension_activation(product) + elsif !product.free? && params[:token].blank? + verify_payg_extension_activation!(product) end rescue InstanceVerification::Exception => e - unless @system.proxy_byos + unless @system.byos? # BYOS instances that use RMT as a proxy are expected to fail the # instance verification check, however, PAYG instances may send registration # code, as such, instance verification engine checks for those BYOS @@ -79,11 +79,10 @@ def verify_product_activation raise ActionController::TranslatedError.new('Unexpected instance verification error has occurred') end - def verify_extension_activation(product) + def verify_payg_extension_activation!(product) return if product.free? base_product = @system.products.find_by(product_type: :base) - subscription = Subscription.joins(:product_classes).find_by( subscription_product_classes: { product_class: base_product.product_class @@ -91,7 +90,7 @@ def verify_extension_activation(product) ) # This error would occur only if there's a problem with subscription setup on SCC side - raise "Can't find a subscription for base product #{base_product.product_string}" unless subscription + raise InstanceVerification::Exception, "Can't find a subscription for base product #{base_product.product_string}" unless subscription allowed_product_classes = subscription.product_classes.pluck(:product_class) @@ -100,6 +99,8 @@ def verify_extension_activation(product) 'The product is not available for this instance' ) end + logger.info "Product #{@product.product_string} available for this instance" + InstanceVerification.update_cache(request.remote_ip, @system.login, product.id) end def verify_base_product_activation(product) diff --git a/engines/instance_verification/lib/instance_verification/providers/example.rb b/engines/instance_verification/lib/instance_verification/providers/example.rb index b773d7855..e4379c39b 100644 --- a/engines/instance_verification/lib/instance_verification/providers/example.rb +++ b/engines/instance_verification/lib/instance_verification/providers/example.rb @@ -27,10 +27,6 @@ def validate_instance_data(_instance_data) end def parse_instance_data - if @instance_data.include? '' - return { 'instance_data' => 'parsed_instance_data' } - end - if @instance_data.include?('SUSE') if @instance_data.include?('SAP') return { 'billingProducts' => nil, 'marketplaceProductCodes' => ['6789_SUSE_SAP'] } @@ -49,4 +45,14 @@ def payg_billing_code?(iid, identifier) return true if (identifier.casecmp('sles').zero? && instance_billing_info[:billing_product] == SLES_PRODUCT_IDENTIFIER) return true if (identifier.casecmp('sles_sap').zero? && SLES4SAP_PRODUCT_IDENTIFIER.include?(instance_billing_info[:marketplace_code])) end + + def instance_identifier + 'foo' + end + + def allowed_extension? + # method to check if a product (extension) meets the criteria + # to be activated on SCC or not, i.e. LTSS in Azure Basic VM + true + end end diff --git a/engines/instance_verification/spec/requests/api/connect/v3/systems/products_controller_spec.rb b/engines/instance_verification/spec/requests/api/connect/v3/systems/products_controller_spec.rb index a8cc64bcc..6b7afad35 100644 --- a/engines/instance_verification/spec/requests/api/connect/v3/systems/products_controller_spec.rb +++ b/engines/instance_verification/spec/requests/api/connect/v3/systems/products_controller_spec.rb @@ -1,5 +1,6 @@ require 'rails_helper' +# rubocop:disable RSpec/NestedGroups describe Api::Connect::V3::Systems::ProductsController, type: :request do include_context 'auth header', :system, :login, :password include_context 'version header', 3 @@ -8,7 +9,7 @@ let(:headers) { auth_header.merge(version_header) } let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions) } let(:product_sap) { FactoryBot.create(:product, :product_sles_sap, :with_mirrored_repositories, :with_mirrored_extensions) } - + let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } let(:payload) do { identifier: product.identifier, @@ -20,200 +21,553 @@ describe '#activate' do let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } - context "when system doesn't have hw_info" do - let(:system) { FactoryBot.create(:system) } - - it 'class instance verification provider' do - expect(InstanceVerification::Providers::Example).to receive(:new) - .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, nil).and_call_original - allow(File).to receive(:directory?) - allow(Dir).to receive(:mkdir) - allow(FileUtils).to receive(:touch) - post url, params: payload, headers: headers - end - end - - context 'when system has hw_info' do - let(:instance_data) { 'dummy_instance_data' } - let(:system) { FactoryBot.create(:system, :with_system_information, instance_data: instance_data) } - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end + after { FileUtils.rm_rf(File.dirname(Rails.application.config.registry_cache_dir)) } - let(:serialized_service_sap_json) do - V3::ServiceSerializer.new( - product_sap.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end + context 'when the system is byos' do + context "when system doesn't have hw_info" do + let(:system) { FactoryBot.create(:system, :byos) } - context 'when verification provider returns false' do before do + stub_request(:post, 'https://scc.suse.com/connect/systems/products') + .to_return( + status: 201, + body: { ok: 'ok' }.to_json, + headers: {} + ) + end + + it 'class instance verification provider' do expect(InstanceVerification::Providers::Example).to receive(:new) - .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double) - expect(plugin_double).to receive(:instance_valid?).and_return(false) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, nil).and_call_original.at_least(:once) + allow(File).to receive(:directory?) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) post url, params: payload, headers: headers end + end - it 'renders an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Unexpected instance verification error has occurred') + context 'when system has hw_info' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) { FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data) } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + let(:serialized_service_byos_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + context 'when verification provider returns false' do + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 200, + body: { error: 'Unexpected instance verification error has occurred' }.to_json, + headers: {} + ) + allow(InstanceVerification::Providers::Example).to receive(:new).and_return(plugin_double) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + allow(plugin_double).to receive(:instance_valid?).and_return(false) + post url, params: payload, headers: headers + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end + end + + context 'when verification provider raises an unhandled exception' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 422, + body: { error: 'Unexpected instance verification error has occurred' }.to_json, + headers: {} + ) + + post url, params: payload, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end end end + end - context 'when verification provider raises an unhandled exception' do - before do + context 'when the system is payg' do + context "when system doesn't have hw_info" do + let(:system) { FactoryBot.create(:system, :payg) } + + it 'class instance verification provider' do expect(InstanceVerification::Providers::Example).to receive(:new) - .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double) - expect(plugin_double).to receive(:instance_valid?).and_raise('Custom plugin error') + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, nil).and_call_original.at_least(:once) + allow(File).to receive(:directory?) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) post url, params: payload, headers: headers end + end - it 'renders an error with exception details' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Unexpected instance verification error has occurred') + context 'when system has hw_info' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) { FactoryBot.create(:system, :payg, :with_system_information, instance_data: instance_data) } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + context 'when verification provider returns false' do + before do + expect(InstanceVerification::Providers::Example).to receive(:new) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double).at_least(:once) + expect(plugin_double).to receive(:instance_valid?).and_return(false) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + post url, params: payload, headers: headers + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end + end + + context 'when verification provider raises an unhandled exception' do + before do + expect(InstanceVerification::Providers::Example).to receive(:new) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double).at_least(:once) + expect(plugin_double).to receive(:instance_valid?).and_raise('Custom plugin error') + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + post url, params: payload, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end + end + + context 'when verification provider raises an instance verification exception' do + let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } + + before do + expect(InstanceVerification::Providers::Example).to receive(:new) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double).at_least(:once) + expect(plugin_double).to receive(:instance_valid?).and_raise(InstanceVerification::Exception, 'Custom plugin error') + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + post url, params: payload, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Instance verification failed: Custom plugin error') + end end end - context 'when verification provider raises an instance verification exception' do + context 'when activating extensions with errors' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) do + FactoryBot.create( + :system, :payg, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data + ) + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + instance_data: 'dummy_instance_data', + proxy_byos_mode: :payg, + hwinfo: + { + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' + } + } + end + let(:scc_response_body) do + { + id: 1234567, + login: 'SCC_3b336b126db1503a9513a14e92a6a62e', + password: '24f057b7941e80f9cf2d51e16e8af2d6' + }.to_json + end before do - expect(InstanceVerification::Providers::Example).to receive(:new) - .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double) - expect(plugin_double).to receive(:instance_valid?).and_raise(InstanceVerification::Exception, 'Custom plugin error') + allow(InstanceVerification::Providers::Example).to receive(:new).and_return(plugin_double) + allow(plugin_double).to receive(:parse_instance_data).and_return({ InstanceId: 'foo' }) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + + FactoryBot.create(:subscription, product_classes: product_classes) stub_request(:post, scc_activate_url) .to_return( status: 401, - body: 'bar', + body: { error: 'Instance verification failed: The product is not available for this instance' }.to_json, headers: {} ) + # stub the fake announcement call PAYG has to do to SCC + # to create the system before activate product (and skip validation) + stub_request(:post, 'https://scc.suse.com/connect/subscriptions/systems') + .to_return(status: 201, body: scc_response_body, headers: {}) post url, params: payload, headers: headers end - it 'renders an error with exception details' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Instance verification failed: Custom plugin error') + context 'when the extension is not free' do + let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + + context 'when a suitable subscription is not found' do + let(:product) do + FactoryBot.create( + :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] + ) + end + let(:product_classes) { [base_product.product_class] } + + it 'de-registers system from SCC and reports an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Instance verification failed: The product is not available for this instance') + end + end end end - context 'when verification provider returns true' do - let(:payload_sap) do + context 'when activating extensions without errors' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) do + FactoryBot.create( + :system, :payg, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data + ) + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:payload_no_token) do { - identifier: product_sap.identifier.downcase, - version: product_sap.version, - arch: product_sap.arch + identifier: product.identifier, + version: product.version, + arch: product.arch, + instance_data: 'dummy_instance_data', + proxy_byos_mode: system.proxy_byos_mode, + token: 'super_token', + hwinfo: + { + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' + } } end - before do - expect(InstanceVerification::Providers::Example).to receive(:new) - .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload_sap, instance_data).and_call_original - allow(File).to receive(:directory?) - allow(Dir).to receive(:mkdir) - allow(FileUtils).to receive(:touch) - expect(InstanceVerification).to receive(:write_cache_file).once.with('repo/cache', "127.0.0.1-#{system.login}-#{product_sap.id}") - expect(InstanceVerification).to receive(:write_cache_file).once.with('registry/cache', "127.0.0.1-#{system.login}") - post url, params: payload_sap, headers: headers + let(:scc_response_body) do + { + id: 42, + login: 'SCC_3b336b126db1503a9513a14e92a6a62e', + password: '24f057b7941e80f9cf2d51e16e8af2d6' + }.to_json end - it 'renders service JSON' do - expect(response.body).to eq(serialized_service_sap_json) + context 'when a suitable subscription is found' do + let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + + let(:product) do + FactoryBot.create( + :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] + ) + end + let(:product_classes) { [base_product.product_class, product.product_class] } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:scc_annouce_body) do + { + hostname: system.hostname, + hwinfo: JSON.parse(system.system_information), + byos_mode: 'hybrid', + login: system.login, + password: system.password + } + end + let(:scc_announce_headers) do + { + Accept: 'application/json,application/vnd.scc.suse.com.v4+json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + Authorization: 'Token token=super_token', + 'Content-Type' => 'application/json', + 'User-Agent' => 'Ruby' + } + end + + before do + allow(InstanceVerification::Providers::Example).to receive(:new).and_return(plugin_double) + allow(plugin_double).to receive(:instance_identifier).and_return('foo') + allow(plugin_double).to receive(:parse_instance_data).and_return({ InstanceId: 'foo' }) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + + allow(InstanceVerification).to receive(:update_cache).with('127.0.0.1', system.login, product.id) + FactoryBot.create(:subscription, product_classes: product_classes) + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: {}.to_json, + headers: {} + ) + # stub the fake announcement call PAYG has to do to SCC + # to create the system before activate product (and skip validation) + stub_request(:post, 'https://scc.suse.com/connect/subscriptions/systems') + .with({ headers: scc_announce_headers, body: scc_annouce_body.to_json }) + .to_return(status: 201, body: scc_response_body, headers: {}) + + expect(InstanceVerification).to receive(:update_cache).with('127.0.0.1', system.login, product.id) + + post url, params: payload_no_token, headers: headers + end + + context 'when regcode is provided' do + it 'returns service JSON' do + expect(response.body).to eq(serialized_service_json) + end + end + end + + context 'when the extension is free' do + let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + let(:product) do + FactoryBot.create( + :product, :with_mirrored_repositories, :extension, free: true, base_products: [base_product] + ) + end + let(:product_classes) { [base_product.product_class] } + + before do + allow(InstanceVerification::Providers::Example).to receive(:new) + .and_return(plugin_double) + allow(plugin_double).to receive(:parse_instance_data).and_return({ InstanceId: 'foo' }) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + + allow(InstanceVerification).to receive(:update_cache).with('127.0.0.1', system.login, product.id) + FactoryBot.create(:subscription, product_classes: product_classes) + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: {}.to_json, + headers: {} + ) + # stub the fake announcement call PAYG has to do to SCC + # to create the system before activate product (and skip validation) + stub_request(:post, 'https://scc.suse.com/connect/subscriptions/systems') + .to_return(status: 201, body: scc_response_body, headers: {}) + + expect(InstanceVerification).not_to receive(:update_cache).with('127.0.0.1', system.login, product.id) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + post url, params: payload_no_token, headers: headers + end + + it 'returns service JSON' do + expect(response.body).to eq(serialized_service_json) + end end end end - end - context 'when activating extensions' do - let(:instance_data) { 'dummy_instance_data' } - let(:system) do - FactoryBot.create( - :system, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data - ) - end - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end - let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } - - before do - FactoryBot.create(:subscription, product_classes: product_classes) - expect(InstanceVerification::Providers::Example).not_to receive(:new) - stub_request(:post, scc_activate_url) - .to_return( - status: 401, - body: 'bar', - headers: {} - ) - - post url, params: payload, headers: headers - end + context 'when the system is hybrid' do + before do + allow_any_instance_of(InstanceVerification::Providers::Example).to receive(:allowed_extension?).and_return(true) + end - context 'when the extension is not free' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + context "when system doesn't have hw_info" do + let(:system) { FactoryBot.create(:system, :hybrid) } - context 'when a suitable subscription is not found' do - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] - ) + it 'class instance verification provider' do + expect(InstanceVerification::Providers::Example).to receive(:new) + .and_call_original.at_least(:once) + allow(File).to receive(:directory?) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + post url, params: payload, headers: headers end - let(:product_classes) { [base_product.product_class] } + end - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Instance verification failed: The product is not available for this instance') + context 'when system has hw_info' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) { FactoryBot.create(:system, :hybrid, :with_system_information, instance_data: instance_data) } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - end - context 'when a suitable subscription is found' do - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] - ) + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - let(:product_classes) { [base_product.product_class, product.product_class] } - it 'returns service JSON' do - expect(response.body).to eq(serialized_service_json) + context 'when verification provider returns false' do + before do + expect(InstanceVerification::Providers::Example).to receive(:new) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double).at_least(:once) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + expect(plugin_double).to receive(:instance_valid?).and_return(false) + post url, params: payload, headers: headers + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end + end + + context 'when verification provider raises an unhandled exception' do + before do + expect(InstanceVerification::Providers::Example).to receive(:new) + .with(be_a(ActiveSupport::Logger), be_a(ActionDispatch::Request), payload, instance_data).and_return(plugin_double).at_least(:once) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + expect(plugin_double).to receive(:instance_valid?).and_raise('Custom plugin error') + post url, params: payload, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Unexpected instance verification error has occurred') + end end end end - context 'when the extension is free' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - let(:product) do + context 'when activating extensions with errors' do + let(:instance_data) { 'dummy_instance_data' } + let(:system) do FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: true, base_products: [base_product] - ) + :system, :hybrid, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data + ) + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + instance_data: 'dummy_instance_data', + proxy_byos_mode: :hybrid, + hwinfo: + { + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' + } + } end - let(:product_classes) { [base_product.product_class] } - it 'returns service JSON' do - expect(response.body).to eq(serialized_service_json) + let(:scc_response_body) do + { + id: 1234567, + login: 'SCC_3b336b126db1503a9513a14e92a6a62e', + password: '24f057b7941e80f9cf2d51e16e8af2d6' + }.to_json end - end + before do + allow(InstanceVerification::Providers::Example).to receive(:new) + .and_return(plugin_double) + allow(plugin_double).to receive(:parse_instance_data).and_return({ InstanceId: 'foo' }) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + + FactoryBot.create(:subscription, product_classes: product_classes) + stub_request(:post, scc_activate_url) + .to_return( + status: 401, + body: { error: 'Instance verification failed: The product is not available for this instance' }.to_json, + headers: {} + ) + # stub the fake announcement call PAYG has to do to SCC + # to create the system before activate product (and skip validation) + stub_request(:post, 'https://scc.suse.com/connect/subscriptions/systems') + .to_return(status: 201, body: scc_response_body, headers: {}) - context 'when the base product subscription is missing' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] - ) + post url, params: payload, headers: headers end - let(:product_classes) { [] } - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Unexpected instance verification error has occurred') + context 'when the extension is not free' do + let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + + context 'when a suitable subscription is found' do + let(:product) do + FactoryBot.create( + :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] + ) + end + let(:product_classes) { [base_product.product_class, product.product_class] } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + + context 'when no regcode is provided' do + it 'activates the product' do + data = JSON.parse(response.body) + expect(data['product']['free']).to eq(false) + expect(data['id']).to eq(product.id) + end + end + end end end end @@ -221,66 +575,178 @@ describe '#upgrade' do subject { response } - let(:system) { FactoryBot.create(:system) } + let(:instance_data) { 'dummy_instance_data' } let(:request) { put url, headers: headers, params: payload } - let!(:old_product) { FactoryBot.create(:product, :with_mirrored_repositories, :activated, system: system) } - let(:payload) do - { - identifier: new_product.identifier, - version: new_product.version, - arch: new_product.arch - } - end - before { request } + context 'when system is byos' do + let(:system) { FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data) } + let!(:old_product) { FactoryBot.create(:product, :with_mirrored_repositories, :activated, system: system) } + let(:payload) do + { + identifier: new_product.identifier, + version: new_product.version, + arch: new_product.arch + } + end + let(:scc_systems_products_url) { 'https://scc.suse.com/connect/systems/products' } + let(:scc_headers) do + { + 'Accept' => 'application/json,application/vnd.scc.suse.com.v4+json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization' => headers['HTTP_AUTHORIZATION'], + 'Content-Type' => 'application/json', + 'User-Agent' => 'Ruby' + } + end + + context 'when SCC upgrade success' do + before do + # pp headers + stub_request(:put, scc_systems_products_url) + .with({ headers: scc_headers, body: payload.merge({ byos_mode: 'byos' }) }) + .and_return(status: 201, body: '', headers: {}) + request + end - context "when migration target base product doesn't have an activated successor/predecessor" do - let(:new_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + context "when migration target base product doesn't have an activated successor/predecessor" do + let(:new_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - it 'HTTP response code is 422' do - expect(response).to have_http_status(422) + it 'HTTP response code is 422' do + expect(response).to have_http_status(422) + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Migration target not allowed on this instance type') + end + end + + context 'when migration target base product has the same identifier' do + let(:new_product) do + FactoryBot.create( + :product, :with_mirrored_repositories, identifier: old_product.identifier, + version: '999', predecessors: [ old_product ] + ) + end + + it 'HTTP response code is 201' do + expect(response).to have_http_status(201) + end + + it "doesn't render an error" do + data = JSON.parse(response.body) + expect(data).not_to have_key('error') + end + end end - it 'renders an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Migration target not allowed on this instance type') + context 'when SCC upgrade fails' do + before do + stub_request(:put, scc_systems_products_url) + .with({ headers: scc_headers, body: payload.merge({ byos_mode: 'byos' }) }) + .and_return( + status: 401, + body: 'Migration target not allowed on this instance type', + headers: {} + ) + request + end + + context "when migration target base product doesn't have an activated successor/predecessor" do + let(:new_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + + it 'HTTP response code is 422' do + expect(response).to have_http_status(422) + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Migration target not allowed on this instance type') + end + end + + context 'when migration target base product has the same identifier' do + let(:new_product) do + FactoryBot.create( + :product, :with_mirrored_repositories, identifier: old_product.identifier, + version: '999', predecessors: [ old_product ] + ) + end + + it 'HTTP response code is 422' do + expect(response).to have_http_status(422) + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data).to have_key('error') + end + end end end - context 'when migration target base product has a different identifier' do - let(:new_product) do - FactoryBot.create( - :product, :with_mirrored_repositories, - identifier: old_product.identifier + '-foo', predecessors: [ old_product ] - ) + context 'when system is payg' do + let(:system) { FactoryBot.create(:system, :payg, :with_system_information, instance_data: instance_data) } + let!(:old_product) { FactoryBot.create(:product, :with_mirrored_repositories, :activated, system: system) } + let(:payload) do + { + identifier: new_product.identifier, + version: new_product.version, + arch: new_product.arch + } end - it 'HTTP response code is 422' do - expect(response).to have_http_status(422) - end + before { request } - it 'renders an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Migration target not allowed on this instance type') - end - end + context "when migration target base product doesn't have an activated successor/predecessor" do + let(:new_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - context 'when migration target base product has the same identifier' do - let(:new_product) do - FactoryBot.create( - :product, :with_mirrored_repositories, identifier: old_product.identifier, - version: '999', predecessors: [ old_product ] - ) + it 'HTTP response code is 422' do + expect(response).to have_http_status(422) + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Migration target not allowed on this instance type') + end end - it 'HTTP response code is 201' do - expect(response).to have_http_status(201) + context 'when migration target base product has a different identifier' do + let(:new_product) do + FactoryBot.create( + :product, :with_mirrored_repositories, + identifier: old_product.identifier + '-foo', predecessors: [ old_product ] + ) + end + + it 'HTTP response code is 422' do + expect(response).to have_http_status(422) + end + + it 'renders an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Migration target not allowed on this instance type') + end end - it "doesn't render an error" do - data = JSON.parse(response.body) - expect(data).not_to have_key('error') + context 'when migration target base product has the same identifier' do + let(:new_product) do + FactoryBot.create( + :product, :with_mirrored_repositories, identifier: old_product.identifier, + version: '999', predecessors: [ old_product ] + ) + end + + it 'HTTP response code is 201' do + expect(response).to have_http_status(201) + end + + it "doesn't render an error" do + data = JSON.parse(response.body) + expect(data).not_to have_key('error') + end end end end end +# rubocop:enable RSpec/NestedGroups diff --git a/engines/registration_sharing/app/controllers/registration_sharing/rmt_to_rmt_controller.rb b/engines/registration_sharing/app/controllers/registration_sharing/rmt_to_rmt_controller.rb index 7d06ddef6..66cb118d2 100644 --- a/engines/registration_sharing/app/controllers/registration_sharing/rmt_to_rmt_controller.rb +++ b/engines/registration_sharing/app/controllers/registration_sharing/rmt_to_rmt_controller.rb @@ -10,6 +10,13 @@ def create system = System.find_or_create_by(login: params[:login]) system.update(system_params) + # TODO: remove this block when proxy_byos column gets dropped + if !params.key?(:proxy_byos_mode) && system.attribute_names.include?('proxy_byos_mode') + # the info comes from a sibling that does not have proxy_byos_mode + # to a sibling does have proxy_byos_mode + system.proxy_byos_mode = system.proxy_byos ? :byos : :payg + end + # end todo system.activations = [] params[:activations].each do |activation| product = Product.find_by(id: activation[:product_id]) @@ -34,7 +41,7 @@ def destroy protected def system_params - params.permit(:login, :password, :hostname, :proxy_byos, :system_token, :registered_at, :created_at, :last_seen_at, :instance_data) + params.permit(:login, :password, :hostname, :proxy_byos, :proxy_byos_mode, :system_token, :registered_at, :created_at, :last_seen_at, :instance_data) end def authenticate diff --git a/engines/registration_sharing/lib/registration_sharing/client.rb b/engines/registration_sharing/lib/registration_sharing/client.rb index 3028facb9..6ac9e35cd 100644 --- a/engines/registration_sharing/lib/registration_sharing/client.rb +++ b/engines/registration_sharing/lib/registration_sharing/client.rb @@ -21,7 +21,7 @@ def sync_system def peer_register_system(system) params = {} - %w[login password hostname proxy_byos system_token registered_at created_at last_seen_at instance_data].each do |attribute| + %w[login password hostname proxy_byos proxy_byos_mode system_token registered_at created_at last_seen_at instance_data].each do |attribute| params[attribute] = system.send(attribute) end diff --git a/engines/registration_sharing/spec/lib/registration_sharing/client_spec.rb b/engines/registration_sharing/spec/lib/registration_sharing/client_spec.rb index 4c9ce8ce1..38c1c9e67 100644 --- a/engines/registration_sharing/spec/lib/registration_sharing/client_spec.rb +++ b/engines/registration_sharing/spec/lib/registration_sharing/client_spec.rb @@ -32,7 +32,8 @@ 'login' => system.login, 'password' => system.password, 'hostname' => system.hostname, - 'proxy_byos' => system.proxy_byos, + 'proxy_byos' => system.byos?, + 'proxy_byos_mode' => system.proxy_byos_mode, 'system_token' => system.system_token, 'registered_at' => system.registered_at, 'created_at' => system.created_at, diff --git a/engines/registration_sharing/spec/requests/registration_sharing/rmt_to_rmt_controller_spec.rb b/engines/registration_sharing/spec/requests/registration_sharing/rmt_to_rmt_controller_spec.rb index f03558390..d08c3a722 100644 --- a/engines/registration_sharing/spec/requests/registration_sharing/rmt_to_rmt_controller_spec.rb +++ b/engines/registration_sharing/spec/requests/registration_sharing/rmt_to_rmt_controller_spec.rb @@ -1,9 +1,11 @@ require 'rails_helper' require 'securerandom' +# rubocop:disable Metrics/ModuleLength module RegistrationSharing RSpec.describe RmtToRmtController, type: :request do - let(:login) { SecureRandom.hex } + let(:login_payg) { SecureRandom.hex } + let(:login_byos) { SecureRandom.hex } let(:password) { SecureRandom.hex } let(:created_at) { Time.zone.now.round - 60 } let(:registered_at) { created_at + 5 } @@ -18,16 +20,17 @@ module RegistrationSharing allow(Settings).to receive(:[]).with(:regsharing).and_return({ api_secret: api_secret }) end - describe '#create' do + describe '#create byos' do before do post( '/api/regsharing', params: { - login: login, + login: login_byos, password: password, created_at: created_at, registered_at: registered_at, last_seen_at: last_seen_at, + proxy_byos: true, activations: [ { product_id: product.id, @@ -54,20 +57,21 @@ module RegistrationSharing end context 'system' do - subject(:system) { System.find_by(login: login) } + subject(:system) { System.find_by(login: login_byos) } it { is_expected.not_to eq(nil) } its(:password) { is_expected.to eq(password) } its(:created_at) { is_expected.to eq(created_at) } its(:registered_at) { is_expected.to eq(registered_at) } its(:last_seen_at) { is_expected.to eq(last_seen_at) } + its(:proxy_byos_mode) { is_expected.to eq('byos') } it 'saves instance data' do expect(system.instance_data).to eq(instance_data) end end context 'activation' do - subject(:activation) { System.find_by(login: login).activations.first } + subject(:activation) { System.find_by(login: login_byos).activations.first } it { is_expected.not_to eq(nil) } it 'has correct product_id' do @@ -78,6 +82,87 @@ module RegistrationSharing end end + describe '#create payg' do + before do + post( + '/api/regsharing', + params: { + login: login_payg, + password: password, + created_at: created_at, + registered_at: registered_at, + last_seen_at: last_seen_at, + proxy_byos: false, + activations: [ + { + product_id: product.id, + created_at: created_at + } + ], + instance_data: instance_data + }, + headers: { 'Authorization' => "Bearer #{request_token}" } + ) + end + + context 'with correct credentials' do + it 'performs HTTP request successfully' do + expect(response).to have_http_status(204) + end + + context 'system' do + subject(:system) { System.find_by(login: login_payg) } + + it { is_expected.not_to eq(nil) } + its(:proxy_byos_mode) { is_expected.to eq('payg') } + it 'saves instance data' do + expect(system.instance_data).to eq(instance_data) + end + end + end + end + + describe '#create hybrid' do + before do + post( + '/api/regsharing', + params: { + login: login_payg, + password: password, + created_at: created_at, + registered_at: registered_at, + last_seen_at: last_seen_at, + proxy_byos_mode: :hybrid, + activations: [ + { + product_id: product.id, + created_at: created_at + } + ], + instance_data: instance_data + }, + headers: { 'Authorization' => "Bearer #{request_token}" } + ) + end + + context 'with correct credentials' do + it 'performs HTTP request successfully' do + expect(response).to have_http_status(204) + end + + context 'system' do + subject(:system) { System.find_by(login: login_payg) } + + it { is_expected.not_to eq(nil) } + its(:proxy_byos_mode) { is_expected.to eq('hybrid') } + its(:proxy_byos) { is_expected.to eq(false) } + it 'saves instance data' do + expect(system.instance_data).to eq(instance_data) + end + end + end + end + describe '#destroy' do let!(:system) { FactoryBot.create(:system) } @@ -109,3 +194,4 @@ module RegistrationSharing end end end +# rubocop:enable Metrics/ModuleLength diff --git a/engines/registry/app/controllers/registry/registry_controller.rb b/engines/registry/app/controllers/registry/registry_controller.rb index 4dc26b84e..2eccc6f7f 100644 --- a/engines/registry/app/controllers/registry/registry_controller.rb +++ b/engines/registry/app/controllers/registry/registry_controller.rb @@ -1,4 +1,6 @@ module Registry + class RegistryAuthError < ArgumentError; end + class RegistryController < Registry::ApplicationController REGISTRY_SERVICE = 'SUSE Linux OCI Registry'.freeze REGISTRY_API_VERSION = 'registry/2.0'.freeze @@ -19,12 +21,23 @@ def authorize # Returns a Distribution Registry HTTP API V2 - compatible repository catalog as defined in # https://distribution.github.io/distribution/spec/api/#listing-repositories def catalog + raise RegistryAuthError, 'Could not find system with current credentials' unless @client && @client.account + access_scope = AccessScope.parse('registry:catalog:*') repos = access_scope.allowed_paths(System.find_by(login: @client&.account)) logger.debug("Returning #{repos.size} repos for client #{@client}") response.set_header('Docker-Distribution-Api-Version', REGISTRY_API_VERSION) render json: { repositories: repos }, status: :ok + rescue RegistryAuthError => e + logger.error("Could not search in catalog: #{e.message}") + error = if e.message.include?('credentials') + ActionController::TranslatedError.new(N_('Please, re-authenticate')) + else + ActionController::TranslatedError.new(N_(e.message)) + end + error.status = :unauthorized + render json: { error: error.message }.to_json, status: :unauthorized end private diff --git a/engines/registry/app/models/access_scope.rb b/engines/registry/app/models/access_scope.rb index dece46643..b15c0b17b 100644 --- a/engines/registry/app/models/access_scope.rb +++ b/engines/registry/app/models/access_scope.rb @@ -83,11 +83,30 @@ def allowed_paths(system = nil) access_policies_yml = YAML.safe_load( File.read(Rails.application.config.access_policies) ) - active_products = system.activations.includes(:product).pluck(:product_class) - - allowed_products = (active_products & access_policies_yml.keys) - allowed_glob_paths = access_policies_yml.values_at(*allowed_products).flatten - + active_product_classes = system.activations.includes(:product).pluck(:product_class) + allowed_product_classes = (active_product_classes & access_policies_yml.keys) + if system && system.hybrid? + # if the system is hybrid => check if the non free product subscription is still valid for accessing images + allowed_non_free_product_classes = allowed_product_classes.map { |s| s unless Product.find_by(product_class: s, product_type: 'extension').free? }.compact + unless allowed_non_free_product_classes.empty? + auth_header = { + 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) + } + allowed_non_free_product_classes.each do |non_free_prod_class| + activation_state = SccProxy.scc_check_subscription_expiration( + auth_header, system, non_free_prod_class + ) + unless activation_state[:is_active] + Rails.logger.info( + "Access to #{non_free_prod_class} from system #{system.login} denied: #{activation_state[:message]}" + ) + # remove the non active non free product extension from the allowed paths + allowed_product_classes -= [non_free_prod_class] + end + end + end + end + allowed_glob_paths = access_policies_yml.values_at(*allowed_product_classes).flatten @allowed_paths = parse_repos(repo_list, allowed_glob_paths) end diff --git a/engines/registry/app/models/registry/authenticated_client.rb b/engines/registry/app/models/registry/authenticated_client.rb index 3a63902ac..2b34baa59 100644 --- a/engines/registry/app/models/registry/authenticated_client.rb +++ b/engines/registry/app/models/registry/authenticated_client.rb @@ -1,28 +1,19 @@ class Registry::AuthenticatedClient include RegistryClient - attr_reader :auth_strategy - def initialize(login, password, remote_ip) raise Registry::Exceptions::InvalidCredentials.new(message: 'expired credentials', login: login) unless cache_file_exist?(remote_ip, login) - authenticate_by_system_credentials(login, password) - if @auth_strategy - Rails.logger.info("Authenticated '#{self}'") - else - raise Registry::Exceptions::InvalidCredentials.new(login: login) - end + raise Registry::Exceptions::InvalidCredentials.new(login: login) unless authenticate_by_system_credentials(login, password) + + Rails.logger.info("Authenticated '#{self}'") end private def authenticate_by_system_credentials(login, password) @systems = System.get_by_credentials(login, password) - if @systems.present? - @account = login - @auth_strategy = :system_credentials - end - @auth_strategy + @account = login if @systems.present? end def cache_file_exist?(remote_ip, login) diff --git a/engines/registry/lib/registry/engine.rb b/engines/registry/lib/registry/engine.rb index aa5481f12..4aacf2f59 100644 --- a/engines/registry/lib/registry/engine.rb +++ b/engines/registry/lib/registry/engine.rb @@ -12,9 +12,11 @@ class Engine < ::Rails::Engine config.after_initialize do Api::Connect::V3::Systems::ActivationsController.class_eval do - before_action :handle_auth_cache, only: %w[index] + # only run instance verification if the instance metadata is present + # and run the cache refresh if instance metadata gets verified + before_action :refresh_auth_cache, only: %w[index], if: -> { request.headers['X-Instance-Data'] } - def handle_auth_cache + def refresh_auth_cache unless ZypperAuth.verify_instance(request, logger, @system) render(xml: { error: 'Instance verification failed' }, status: :forbidden) end diff --git a/engines/registry/spec/app/controllers/registry_controller_spec.rb b/engines/registry/spec/app/controllers/registry_controller_spec.rb index c1e9cfcb1..471d9c188 100644 --- a/engines/registry/spec/app/controllers/registry_controller_spec.rb +++ b/engines/registry/spec/app/controllers/registry_controller_spec.rb @@ -1,3 +1,4 @@ +# rubocop:disable Metrics/ModuleLength module Registry describe RegistryController, type: :request do describe '#authenticate' do @@ -45,8 +46,6 @@ module Registry end describe '#catalog access' do - let(:system) { create(:system) } - let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } let(:auth_headers_token) { {} } let(:fake_response) { { repositories: repositories_returned } } @@ -55,57 +54,138 @@ module Registry end let(:authorize_url) { 'api/registry/authorize' } let(:root_url) { 'smt-ec2.susecloud.net' } - let(:params_catalog) { "account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry" } let(:access_policy_content) { File.read('engines/registry/spec/data/access_policy_yaml.yml') } let(:registry_conf) { { root_url: root_url } } - before do - stub_request(:get, "https://registry-example.susecloud.net/api/registry/authorize?account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry") - .with( - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'User-Agent' => 'Ruby' - } -).to_return(status: 200, body: JSON.dump({ foo: 'foo' }), headers: {}) + context 'with valid credentials' do + let(:system) { create(:system) } + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } + let(:params_catalog) { "account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry" } + + before do + stub_request(:get, "https://registry-example.susecloud.net/api/registry/authorize?account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry") + .with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ).to_return(status: 200, body: JSON.dump({ foo: 'foo' }), headers: {}) + + stub_request(:get, "#{RegistryCatalogService.new.catalog_api_url}?n=1000") + .to_return(body: JSON.dump(fake_response), status: 200, headers: { 'Content-type' => 'application/json' }) + end - stub_request(:get, "#{RegistryCatalogService.new.catalog_api_url}?n=1000") - .to_return(body: JSON.dump(fake_response), status: 200, headers: { 'Content-type' => 'application/json' }) - end + context 'with a valid token' do + it 'has catalog access' do + allow(File).to receive(:read).and_return(access_policy_content) + allow_any_instance_of(AuthenticatedClient).to receive(:cache_file_exist?).and_return(true) + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) + + auth_headers_token['Authorization'] = format("Bearer #{json_response[:token]}") + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:ok) + end + end - context 'with a valid token' do - it 'has catalog access' do - allow(File).to receive(:read).and_return(access_policy_content) - allow_any_instance_of(AuthenticatedClient).to receive(:cache_file_exist?).and_return(true) - get( - '/api/registry/authorize', - params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, - headers: auth_headers - ) + context 'when token is invalid' do + it 'denies the access' do + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) - auth_headers_token['Authorization'] = format("Bearer #{json_response[:token]}") - get('/api/registry/catalog', headers: auth_headers_token) + auth_headers_token['Authorization'] = format('Bearer foo') - expect(response).to have_http_status(:ok) + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:unauthorized) + end end - end - context 'when token is invalid' do - it 'raise an exception' do - get( - '/api/registry/authorize', - params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, - headers: auth_headers - ) + context 'when an error happens' do + it 'denies the access' do + allow_any_instance_of(AccessScope).to receive(:allowed_paths).and_raise(RegistryAuthError, 'Foo') + allow(File).to receive(:read).and_return(access_policy_content) + allow_any_instance_of(AuthenticatedClient).to receive(:cache_file_exist?).and_return(true) + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) + + auth_headers_token['Authorization'] = format("Bearer #{json_response[:token]}") + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:unauthorized) + end + end + end - auth_headers_token['Authorization'] = format('Bearer foo') + context 'with invalid credentials' do + let(:system) { create(:system) } + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } + let(:jwt_payload) do + [ + { + 'iss' => 'RMT', + 'sub' => nil, + 'aud' => 'SUSE Linux OCI Registry', + 'exp' => 1724155172, + 'nbf' => 1724154872, + 'iat' => 1724154872, + 'jti' => 'NWRhY2VlYTAtNWE1Mi00NmYzLWI4MTEtZDdiYzRkYjE1OWRm', + 'access' => [{ 'type' => 'registry', 'class' => nil, 'name' => 'catalog', 'actions' => ['*'] }] + }, + { + 'kid' => 'C7TL:6AHY:F4L2:PJT2:QSOT:AACT:QPDE:VPK3:3BEG:SJNF:Q52E:OIZR', + 'alg' => 'RS256' + } + ] + end - get('/api/registry/catalog', headers: auth_headers_token) + before do + allow(JWT).to receive(:decode).and_return(jwt_payload) + allow_any_instance_of(Registry::AuthenticatedClient).to receive(:cache_file_exist?).and_return(true) + stub_request(:get, 'https://registry-example.susecloud.net/api/registry/authorize?&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry') + .with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ).to_return(status: 200, body: JSON.dump({ foo: 'foo' }), headers: {}) + + stub_request(:get, "#{RegistryCatalogService.new.catalog_api_url}?n=1000") + .to_return(body: JSON.dump(fake_response), status: 200, headers: { 'Content-type' => 'application/json' }) + end - expect(response).to have_http_status(:unauthorized) + context 'with a valid token' do + it 'can not find system' do + allow(File).to receive(:read).and_return(access_policy_content) + allow_any_instance_of(AuthenticatedClient).to receive(:cache_file_exist?).and_return(true) + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) + + auth_headers_token['Authorization'] = format("Bearer #{json_response[:token]}") + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(body)['error']).to eq('Please, re-authenticate') + end end end end end end +# rubocop:enable Metrics/ModuleLength diff --git a/engines/registry/spec/app/models/access_scope_spec.rb b/engines/registry/spec/app/models/access_scope_spec.rb index 1af1d75b1..0d3c84a22 100644 --- a/engines/registry/spec/app/models/access_scope_spec.rb +++ b/engines/registry/spec/app/models/access_scope_spec.rb @@ -105,6 +105,7 @@ end end + # rubocop:disable RSpec/NestedGroups describe ".granted['actions']" do let(:product1) do product = FactoryBot.create(:product, :with_mirrored_repositories) @@ -152,16 +153,16 @@ type: 'a', name: 'suse/sles/*', actions: ['pull'] - ) + ) end it 'returns default auth actions (no free repos included)' do yaml_string = access_policy_content data = YAML.safe_load yaml_string - data[product1.product_class] = 'suse/**' + data[product1.product_class] = 'suse/sles/**' File.write('engines/registry/spec/data/access_policy_yaml.yml', YAML.dump(data)) allow_any_instance_of(RegistryCatalogService).to receive(:repos).and_return(['suse/sles/super_repo']) - allow(File).to receive(:read).and_return(access_policy_content) + allow(File).to receive(:read).and_return(File.read('engines/registry/spec/data/access_policy_yaml.yml')) possible_access = access_scope.granted(client: client) expect(possible_access).to eq( @@ -171,7 +172,77 @@ 'class' => nil, 'name' => 'suse/sles/*' } + ) + end + end + + context 'when product is LTSS' do + subject(:access_scope) do + described_class.new( + type: 'a', + name: 'suse/ltss/**', + actions: ['pull'] + ) + end + + let(:system) do + system = FactoryBot.create(:system, :hybrid) + system.activations << [ + FactoryBot.create(:activation, system: system, service: product1.service), + FactoryBot.create(:activation, system: system, service: product2.service) + ] + system + end + let(:product1) do + product = FactoryBot.create(:product, :with_mirrored_repositories, :extension) + product.repositories.where(enabled: false).update(mirroring_enabled: false) + product.update(product_class: 'SLES15-SP4-LTSS-X86') + product + end + + context 'when activation expired' do + let(:scc_response) do + { + is_active: false, + message: 'You shall not have access to those repos !' + } + end + let(:header_expected) do + { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } + end + + before do + allow(SccProxy).to receive(:scc_check_subscription_expiration) + .with( + header_expected, + system, + 'SLES15-SP4-LTSS-X86' + ).and_return(scc_response) + end + + it 'returns no actions allowed' do + expect(SccProxy).to receive(:scc_check_subscription_expiration).with( + header_expected, + system, + 'SLES15-SP4-LTSS-X86' + ) + yaml_string = access_policy_content + data = YAML.safe_load yaml_string + data[product1.product_class] = 'suse/ltss/**' + File.write('engines/registry/spec/data/access_policy_yaml.yml', YAML.dump(data)) + allow_any_instance_of(RegistryCatalogService).to receive(:repos).and_return(['suse/ltss/ltss_repo']) + allow(File).to receive(:read).and_return(File.read('engines/registry/spec/data/access_policy_yaml.yml')) + possible_access = access_scope.granted(client: client) + + expect(possible_access).to eq( + { + 'type' => 'a', + 'actions' => [], + 'class' => nil, + 'name' => 'suse/ltss/**' + } ) + end end end @@ -226,4 +297,5 @@ end end end + # rubocop:enable RSpec/NestedGroups end diff --git a/engines/registry/spec/app/models/registry/authenticated_client_spec.rb b/engines/registry/spec/app/models/registry/authenticated_client_spec.rb index fef79b66a..2f20d5a5a 100644 --- a/engines/registry/spec/app/models/registry/authenticated_client_spec.rb +++ b/engines/registry/spec/app/models/registry/authenticated_client_spec.rb @@ -24,7 +24,7 @@ it 'returns the auth strategy' do expect(client.systems).to eq([system]) - expect(client.auth_strategy).to eq(:system_credentials) + expect(client.account).to eq(system.login) end end diff --git a/engines/registry/spec/data/access_policy_yaml.yml b/engines/registry/spec/data/access_policy_yaml.yml index 5d6f778c3..ce006e751 100644 --- a/engines/registry/spec/data/access_policy_yaml.yml +++ b/engines/registry/spec/data/access_policy_yaml.yml @@ -23,4 +23,3 @@ free: - suse/sles/** - suse/vmdp/** - trento/** -72AAA: suse/** diff --git a/engines/registry/spec/requests/api/connect/v3/systems/activations_controller_spec.rb b/engines/registry/spec/requests/api/connect/v3/systems/activations_controller_spec.rb index fced324ea..dd2cfcb73 100644 --- a/engines/registry/spec/requests/api/connect/v3/systems/activations_controller_spec.rb +++ b/engines/registry/spec/requests/api/connect/v3/systems/activations_controller_spec.rb @@ -3,20 +3,36 @@ include_context 'version header', 3 describe '#activations' do - let(:system) { FactoryBot.create(:system, :with_activated_product) } + let(:system) { FactoryBot.create(:system, :payg, :with_activated_product) } let(:headers) { auth_header.merge(version_header) } context 'without valid repository cache' do - before do - headers['X-Instance-Data'] = 'IMDS' - allow(ZypperAuth).to receive(:verify_instance).and_return(true) - end - context 'without X-Instance-Data headers or hw_info' do - it 'has service URLs with HTTP scheme' do + it 'does not update InstanceVerification cache' do get '/connect/systems/activations', headers: headers data = JSON.parse(response.body) expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) + expect(InstanceVerification).not_to receive(:update_cache) + end + end + + context 'with X-Instance-Data headers and bad metadata' do + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + + before do + headers['X-Instance-Data'] = 'IMDS' + Thread.current[:logger] = RMT::Logger.new('/dev/null') + end + + it 'does not update InstanceVerification cache' do + allow(plugin_double).to( + receive(:instance_valid?) + .and_raise(InstanceVerification::Exception, 'Custom plugin error') + ) + allow(ZypperAuth).to receive(:verify_instance).and_call_original + get '/connect/systems/activations', headers: headers + expect(response.body).to include('Instance verification failed') + expect(InstanceVerification).not_to receive(:update_cache) end end end @@ -26,10 +42,6 @@ before do allow(File).to receive(:join).and_call_original - # allow(File).to receive(:exist?).with('repo/cache/127.0.0.1-login45-1052122') - # allow(File).to receive(:exist?).with("repo/cache/127.0.0.1-#{system.login}-#{system.products.first.id}").and_return(true) - # allow(File).to receive(:exist?).with("repo/cache/127.0.0.1-#{system.login}-#{system.products.first.id}").and_return(true) - # allow(File).to receive(:exist?) allow(InstanceVerification).to receive(:update_cache) allow(ZypperAuth).to receive(:verify_instance).and_call_original headers['X-Instance-Data'] = 'IMDS' @@ -45,5 +57,68 @@ expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) end end + + context 'system is hybrid' do + let(:system) { FactoryBot.create(:system, :hybrid, :with_activated_product) } + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:cache_name) { "repo/cache/127.0.0.1-#{system.login}-#{system.products.first.id}" } + let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } + let(:body_active) do + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'ACTIVE', + starts_at: 'null', + expires_at: '2014-03-14T13:10:21.164Z', + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system.activations.first.product.id + } + } + } + end + + before do + allow(InstanceVerification::Providers::Example).to receive(:new).and_return(plugin_double) + + allow(plugin_double).to( + receive(:instance_valid?).and_return(true) + ) + allow(File).to receive(:join).and_call_original + allow(InstanceVerification).to receive(:update_cache) + allow(ZypperAuth).to receive(:verify_instance).and_call_original + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_active].to_json, headers: {}) + headers['X-Instance-Data'] = 'IMDS' + end + + context 'no registry' do + it 'refreshes registry cache key only' do + FileUtils.mkdir_p('repo/cache') + expect(InstanceVerification).to receive(:update_cache).with('127.0.0.1', system.login, system.activations.first.product.id) + get '/connect/systems/activations', headers: headers + FileUtils.rm_rf('repo/cache') + data = JSON.parse(response.body) + expect(SccProxy).not_to receive(:product_path_access) + expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) + end + end + + context 'registry' do + it 'refreshes registry cache key only' do + FileUtils.mkdir_p('repo/cache') + FileUtils.touch(cache_name) + expect(InstanceVerification).to receive(:update_cache).with('127.0.0.1', system.login, nil, registry: true) + get '/connect/systems/activations', headers: headers + FileUtils.rm_rf('repo/cache') + data = JSON.parse(response.body) + expect(SccProxy).not_to receive(:product_path_access) + expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) + end + end + end end end diff --git a/engines/scc_proxy/lib/scc_proxy/engine.rb b/engines/scc_proxy/lib/scc_proxy/engine.rb index ef80a9b3b..47e6557e8 100644 --- a/engines/scc_proxy/lib/scc_proxy/engine.rb +++ b/engines/scc_proxy/lib/scc_proxy/engine.rb @@ -2,7 +2,7 @@ require 'net/http' ANNOUNCE_URL = 'https://scc.suse.com/connect/subscriptions/systems'.freeze -ACTIVATE_PRODUCT_URL = 'https://scc.suse.com/connect/systems/products'.freeze +SYSTEM_PRODUCTS_URL = 'https://scc.suse.com/connect/systems/products'.freeze SYSTEMS_ACTIVATIONS_URL = 'https://scc.suse.com/connect/systems/activations'.freeze DEREGISTER_SYSTEM_URL = 'https://scc.suse.com/connect/systems'.freeze DEREGISTER_PRODUCT_URL = 'https://scc.suse.com/connect/systems/products'.freeze @@ -25,12 +25,6 @@ Net::HTTPRetriableError ].freeze -INSTANCE_ID_KEYS = { - amazon: 'instanceId', - google: 'instance_id', - microsoft: 'vmId' -}.freeze - # rubocop:disable Metrics/ModuleLength module SccProxy class << self @@ -43,7 +37,12 @@ class << self # rubocop:disable ThreadSafety/InstanceVariableInClassMethod def headers(auth, params) @instance_id = if params && params.class != String - get_instance_id(params) + InstanceVerification.provider.new( + nil, + nil, + nil, + params['instance_data'] + ).instance_identifier else # if it is not JSON, it is the system_token already # announce system has metadata @@ -61,18 +60,6 @@ def headers(auth, params) end # rubocop:enable ThreadSafety/InstanceVariableInClassMethod - def get_instance_id(params) - verification_provider = InstanceVerification.provider.new( - nil, - nil, - nil, - params['instance_data'] - ) - instance_id_key = INSTANCE_ID_KEYS[params['hwinfo']['cloud_provider'].downcase.to_sym] - iid = verification_provider.parse_instance_data - iid[instance_id_key] - end - def prepare_scc_announce_request(uri_path, auth, params) scc_request = Net::HTTP::Post.new(uri_path, headers(auth, params)) @@ -80,24 +67,50 @@ def prepare_scc_announce_request(uri_path, auth, params) # to SCC. # SCC will make sure to handle the data correctly. This removes the need # to adapt here if information send by the client changes. - scc_request.body = { + scc_req_body = { hostname: params['hostname'], hwinfo: params['hwinfo'], - byos: true - }.to_json + byos_mode: params['proxy_byos_mode'] + } + # when system is payg, we do not know whether it's hybrid or not + # we send the login and password information to skip the validation + # on the SCC side, that info is enough to validate the product later on + # if the system is, in fact, hybrid + # When activating a BYOS extension on top of a PAYG system ('hybrid mode'), + # the system already has credentials in RMT, but is not known to SCC. + # We announce it to SCC including its credentials in this case. + if params['proxy_byos_mode'] == 'hybrid' + scc_req_body[:login] = params['scc_login'] + scc_req_body[:password] = params['scc_password'] + end + scc_request.body = scc_req_body.to_json scc_request end - def prepare_scc_request(uri_path, product, auth, token, email) - scc_request = Net::HTTP::Post.new(uri_path, headers(auth, nil)) + def prepare_scc_request(uri_path, product, auth, params, mode) + params_header = params + params_header = nil if mode == 'byos' + + scc_request = Net::HTTP::Post.new(uri_path, headers(auth, params_header)) scc_request.body = { - token: token, + token: params[:token] || nil, identifier: product.identifier, version: product.version, arch: product.arch, release_type: product.release_type, - email: email || nil, - byos: true + email: params[:email] || nil, + byos_mode: mode + }.to_json + scc_request + end + + def prepare_scc_upgrade_request(uri_path, product, auth, mode) + scc_request = Net::HTTP::Put.new(uri_path, headers(auth, nil)) + scc_request.body = { + identifier: product.identifier, + version: product.version, + arch: product.arch, + byos_mode: mode }.to_json scc_request end @@ -113,15 +126,26 @@ def announce_system_scc(auth, params) JSON.parse(response.body) end - def scc_activate_product(product, auth, token, email) - uri = URI.parse(ACTIVATE_PRODUCT_URL) + def scc_activate_product(system, product, auth, params, mode) + uri = URI.parse(SYSTEM_PRODUCTS_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - scc_request = prepare_scc_request(uri.path, product, auth, token, email) - http.request(scc_request) + scc_request = prepare_scc_request(uri.path, product, auth, params, mode) + response = http.request(scc_request) + unless response.code_type == Net::HTTPCreated + error = JSON.parse(response.body) + Rails.logger.info "Could not activate #{product.product_string}, error: #{error['error']} #{response.code}" + error['error'] = SccProxy.parse_error(error['error']) if error['error'].include? 'json' + # if trying to activate first product on a hybrid system + # it means the system was "just" announced on this call + # if product activation failed, system should get de-register from SCC + SccProxy.deregister_system_scc(auth, system) if system.payg? + + raise ActionController::TranslatedError.new(error['error']) + end end - def deactivate_product_scc(auth, product, params) + def deactivate_product_scc(auth, product, params, logger) uri = URI.parse(DEREGISTER_PRODUCT_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true @@ -132,15 +156,29 @@ def deactivate_product_scc(auth, product, params) arch: product.arch, byos: true }.to_json - http.request(scc_request) + response = http.request(scc_request) + unless response.code_type == Net::HTTPOK + error = JSON.parse(response.body) + error['error'] = SccProxy.parse_error(error['error'], params[:token], params[:email]) if error['error'].include? 'json' + logger.info "Could not de-activate product '#{product.friendly_name}', error: #{error['error']} #{response.code}" + raise ActionController::TranslatedError.new(error['error']) + end + response end - def deregister_system_scc(auth, system_token) + def deregister_system_scc(auth, system) uri = URI.parse(DEREGISTER_SYSTEM_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - scc_request = Net::HTTP::Delete.new(uri.path, headers(auth, system_token)) - http.request(scc_request) + scc_request = Net::HTTP::Delete.new(uri.path, headers(auth, system.system_token)) + response = http.request(scc_request) + unless response.code_type == Net::HTTPNoContent + error = JSON.parse(response.body) + Rails.logger.info "Could not de-activate system #{system.login}, error: #{error['error']} #{response.code}" + error['error'] = SccProxy.parse_error(error['error'], params[:token], params[:email]) if error['error'].include? 'json' + raise ActionController::TranslatedError.new(error['error']) + end + Rails.logger.info 'System successfully deregistered from SCC' end def parse_error(error_message, token = nil, email = nil) @@ -150,48 +188,69 @@ def parse_error(error_message, token = nil, email = nil) error_message end - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/PerceivedComplexity - def scc_check_subscription_expiration(headers, login, system_token, logger) - auth = headers['HTTP_AUTHORIZATION'] if headers.include?('HTTP_AUTHORIZATION') + def get_scc_activations(headers, system) + auth = headers['HTTP_AUTHORIZATION'] if headers && headers.include?('HTTP_AUTHORIZATION') uri = URI.parse(SYSTEMS_ACTIVATIONS_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - uri.query = URI.encode_www_form({ byos: true }) - scc_request = Net::HTTP::Get.new(uri.path, headers(auth, system_token)) + uri.query = URI.encode_www_form({ byos_mode: system.proxy_byos_mode }) + scc_request = Net::HTTP::Get.new(uri.path, headers(auth, system.system_token)) response = http.request(scc_request) unless response.code_type == Net::HTTPOK - logger.info "Could not get the system (#{login}) activations, error: #{response.message} #{response.code}" - response.message = SccProxy.parse_error(response.message) if response.message.include? 'json' - return { is_active: false, message: response.message } + Rails.logger.info "Could not get the system (#{system.login}) activations, error: #{response.message} #{response.code}" + raise ActionController::TranslatedError.new(response.body) end - scc_systems_activations = JSON.parse(response.body) - return { is_active: false, message: 'No activations.' } if scc_systems_activations.empty? + JSON.parse(response.body) + end - no_status_products_ids = scc_systems_activations.map do |act| - act['service']['product']['id'] if (act['status'].nil? && act['expires_at'].nil?) - end.flatten.compact - return { is_active: true } unless no_status_products_ids.all?(&:nil?) + def product_path_access(x_original_uri, products_ids) + products = Product.where(id: products_ids) + product_paths = products.map { |prod| prod.repositories.pluck(:local_path) }.flatten + product_paths.any? { |path| x_original_uri.include?(path) } + end + + def product_class_access(scc_systems_activations, product) + active_products_names = scc_systems_activations.map { |act| act['service']['product']['product_class'] if act['status'].casecmp('active').zero? }.flatten + if active_products_names.include?(product) + { is_active: true } + else + expired_products_names = scc_systems_activations.map do |act| + act['service']['product']['product_class'] unless act['status'].casecmp('active').zero? + end.flatten + message = if expired_products_names.all?(&:nil?) + 'Product not activated.' + elsif expired_products_names.include?(product) + 'Subscription expired.' + else + 'Unexpected error when checking product subscription.' + end + { is_active: false, message: message } + end + end + + # rubocop:disable Metrics/PerceivedComplexity + def activations_fail_state(scc_systems_activations, headers, product = nil) + return SccProxy.product_class_access(scc_systems_activations, product) unless product.nil? active_products_ids = scc_systems_activations.map { |act| act['service']['product']['id'] if act['status'].casecmp('active').zero? }.flatten - products = Product.where(id: active_products_ids) - product_paths = products.map { |prod| prod.repositories.pluck(:local_path) }.flatten - active_subscription = product_paths.any? { |path| headers.fetch('X-Original-URI', '').include?(path) } - if active_subscription + x_original_uri = headers.fetch('X-Original-URI', '') + # if there is no product info to compare the activations with + # probably means the query is to refresh credentials + # in any case, verification is true if ALL activations are ACTIVE + return { is_active: (scc_systems_activations.length == active_products_ids.length) } if x_original_uri.empty? + + if SccProxy.product_path_access(x_original_uri, active_products_ids) { is_active: true } else # product not found in the active subscriptions, check the expired ones expired_products_ids = scc_systems_activations.map { |act| act['service']['product']['id'] unless act['status'].casecmp('active').zero? }.flatten if expired_products_ids.all?(&:nil?) - return { + { is_active: false, message: 'Product not activated.' } end - products = Product.where(id: expired_products_ids) - product_paths = products.map { |prod| prod.repositories.pluck(:local_path) }.flatten - expired_subscription = product_paths.any? { |path| headers.fetch('X-Original-URI', '').include?(path) } - if expired_subscription + if SccProxy.product_path_access(x_original_uri, expired_products_ids) { is_active: false, message: 'Subscription expired.' @@ -205,9 +264,37 @@ def scc_check_subscription_expiration(headers, login, system_token, logger) end end # rubocop:enable Metrics/PerceivedComplexity - # rubocop:enable Metrics/CyclomaticComplexity + + def scc_check_subscription_expiration(headers, system, product = nil) + scc_systems_activations = SccProxy.get_scc_activations(headers, system) + return { is_active: false, message: 'No activations.' } if scc_systems_activations.empty? + + no_status_products_ids = scc_systems_activations.map do |act| + act['service']['product']['id'] if (act['status'].nil? && act['expires_at'].nil?) + end.flatten.compact + return { is_active: true } unless no_status_products_ids.all?(&:nil?) + + SccProxy.activations_fail_state(scc_systems_activations, headers, product) + rescue StandardError + { is_active: false, message: 'Could not check the activations from SCC' } + end + + def scc_upgrade(auth, product, system_login, mode, logger) + uri = URI.parse(SYSTEM_PRODUCTS_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + scc_request = prepare_scc_upgrade_request(uri.path, product, auth, mode) + response = http.request(scc_request) + unless response.code_type == Net::HTTPCreated + logger.info "Could not upgrade the system (#{system_login}), error: #{response.message} #{response.code}" + response.message = SccProxy.parse_error(response.message) if response.message.include? 'json' + raise ActionController::TranslatedError.new(response.body) + end + response + end end + # rubocop:disable Metrics/ClassLength class Engine < ::Rails::Engine isolate_namespace SccProxy config.generators.api_only = true @@ -219,20 +306,28 @@ def announce_system auth_header = nil auth_header = request.headers['HTTP_AUTHORIZATION'] if request.headers.include?('HTTP_AUTHORIZATION') system_information = hwinfo_params[:hwinfo].to_json - + instance_data = params.fetch(:instance_data, nil) if has_no_regcode?(auth_header) # no token sent to check with SCC # standard announce case - @system = System.create!(hostname: params[:hostname], system_information: system_information) + @system = System.create!( + hostname: params[:hostname], + system_information: system_information, + proxy_byos_mode: :payg, + instance_data: instance_data + ) else + request.request_parameters['proxy_byos_mode'] = 'byos' response = SccProxy.announce_system_scc(auth_header, request.request_parameters) @system = System.create!( system_token: SccProxy.instance_id, login: response['login'], password: response['password'], hostname: params[:hostname], + proxy_byos_mode: :byos, proxy_byos: true, - system_information: system_information + system_information: system_information, + instance_data: instance_data ) end logger.info("System '#{@system.hostname}' announced") @@ -260,24 +355,78 @@ def has_no_regcode?(auth_header) Api::Connect::V3::Systems::ProductsController.class_eval do before_action :scc_activate_product, only: %i[activate] + before_action :scc_upgrade, only: %i[upgrade], if: -> { @system.byos? } protected def scc_activate_product - logger.info "Activating product #{@product.product_string} to SCC" - auth = request.headers['HTTP_AUTHORIZATION'] - if @system.proxy_byos - response = SccProxy.scc_activate_product(@product, auth, params[:token], params[:email]) - unless response.code_type == Net::HTTPCreated - error = JSON.parse(response.body) - logger.info "Could not activate #{@product.product_string}, error: #{error['error']} #{response.code}" - error['error'] = SccProxy.parse_error(error['error']) if error['error'].include? 'json' - raise ActionController::TranslatedError.new(error['error']) - end + product_hash = @product.attributes.symbolize_keys.slice(:identifier, :version, :arch) + unless InstanceVerification.provider.new(logger, request, product_hash, @system.instance_data).allowed_extension? + error = ActionController::TranslatedError.new(N_('Product not supported for this instance')) + error.status = :forbidden + raise error + end + mode = find_mode + unless mode.nil? + # if system is byos or hybrid and there is a token + # make a request to SCC + logger.info "Activating product #{@product.product_string} to SCC" + logger.info 'No token provided' if params[:token].blank? + SccProxy.scc_activate_product( + @system, @product, request.headers['HTTP_AUTHORIZATION'], params, mode + ) + # if the system is PAYG and the registration code is valid for the extension, + # then the system is hybrid + # update the system to HYBRID mode if HYBRID MODE and system not HYBRID already + @system.hybrid! if mode == 'hybrid' && @system.payg? + logger.info "Product #{@product.product_string} successfully activated with SCC" InstanceVerification.update_cache(request.remote_ip, @system.login, @product.id) end end + + def find_mode + if @system.byos? + 'byos' + elsif !@product.free? && @product.extension? && params[:token].present? + announce_base_product_hybrid 'hybrid' + 'hybrid' + end + end + + def announce_base_product_hybrid(mode) + # in order for SCC to be able activate the extension (i.e. LTSS) + # the system must be announced to SCC first + base_prod = @system.products.find_by(product_type: :base) + # the extensions must be the same version and arch + # than base product + if @system.payg? && base_prod.present? + raise 'Incompatible extension product' unless @product.arch == base_prod.arch && @product.version == base_prod.version + + update_params_system_info mode + SccProxy.announce_system_scc( + "Token token=#{params[:token]}", params + ) + end + end + + def scc_upgrade + logger.info "Upgrading system to product #{@product.product_string} to SCC" + auth = nil + auth = request.headers['HTTP_AUTHORIZATION'] if request.headers.include?('HTTP_AUTHORIZATION') + mode = 'byos' if @system.byos? + SccProxy.scc_upgrade(auth, @product, @system.login, mode, logger) + logger.info "System #{@system.login} successfully upgraded with SCC" + end + + def update_params_system_info(mode) + params['hostname'] = @system.hostname + params['proxy_byos_mode'] = mode + params['scc_login'] = @system.login + params['scc_password'] = @system.password + params['hwinfo'] = JSON.parse(@system.system_information) + params['instance_data'] = @system.instance_data + end end Api::Connect::V4::Systems::ProductsController.class_eval do @@ -287,16 +436,29 @@ def scc_activate_product def scc_deactivate_product auth = request.headers['HTTP_AUTHORIZATION'] - if @system.proxy_byos && @product[:product_type] != 'base' - response = SccProxy.deactivate_product_scc(auth, @product, @system.system_token) - unless response.code_type == Net::HTTPOK - error = JSON.parse(response.body) - error['error'] = SccProxy.parse_error(error['error'], params[:token], params[:email]) if error['error'].include? 'json' - logger.info "Could not de-activate product '#{@product.friendly_name}', error: #{error['error']} #{response.code}" - raise ActionController::TranslatedError.new(error['error']) + if @system.byos? && @product[:product_type] != 'base' + SccProxy.deactivate_product_scc(auth, @product, @system.system_token, logger) + elsif @system.hybrid? && @product.extension? + # check if product is on SCC and + # if it is -> de-activate it + scc_hybrid_system_activations = SccProxy.get_scc_activations(headers, @system) + if scc_hybrid_system_activations.map { |act| act['service']['product']['id'] == @product.id }.present? + # if product is found on SCC, regardless of the state + # it is OK to remove it from SCC + SccProxy.deactivate_product_scc(auth, @product, @system.system_token, logger) + make_system_payg(auth) if scc_hybrid_system_activations.reject { |act| act['service']['product']['id'] == @product.id }.blank? end - logger.info "Product '#{@product.friendly_name}' successfully deactivated from SCC" end + logger.info "Product '#{@product.friendly_name}' successfully deactivated from SCC" + end + + def make_system_payg(auth) + # if the system does not have more products activated on SCC + # switch it back to payg + # drop the just de-activated activation from the list to avoid another call to SCC + # and check if there is any product + SccProxy.deregister_system_scc(auth, @system) + @system.payg! end end @@ -306,16 +468,9 @@ def scc_deactivate_product protected def scc_deregistration - if @system.proxy_byos - auth = request.headers['HTTP_AUTHORIZATION'] - response = SccProxy.deregister_system_scc(auth, @system.system_token) - unless response.code_type == Net::HTTPNoContent - error = JSON.parse(response.body) - logger.info "Could not de-activate system #{@system.login}, error: #{error['error']} #{response.code}" - error['error'] = SccProxy.parse_error(error['error'], params[:token], params[:email]) if error['error'].include? 'json' - raise ActionController::TranslatedError.new(error['error']) - end - logger.info 'System successfully deregistered from SCC' + if @system.byos? || @system.hybrid? + # byos and hybrid systems should get de-register from SCC + SccProxy.deregister_system_scc(request.headers['HTTP_AUTHORIZATION'], @system) end end end @@ -352,7 +507,7 @@ def authenticate_system(skip_on_duplicated: false) def get_system(systems) return nil if systems.blank? - byos_systems_with_token = systems.select { |system| system.proxy_byos && system.system_token } + byos_systems_with_token = systems.select { |system| system.byos? && system.system_token } return systems.first if byos_systems_with_token.empty? @@ -379,5 +534,6 @@ def get_system(systems) end end end + # rubocop:enable Metrics/ClassLength end # rubocop:enable Metrics/ModuleLength diff --git a/engines/scc_proxy/spec/requests/api/connect/v3/subscriptions/systems_controller_spec.rb b/engines/scc_proxy/spec/requests/api/connect/v3/subscriptions/systems_controller_spec.rb index 150cb6864..442bfd9cf 100644 --- a/engines/scc_proxy/spec/requests/api/connect/v3/subscriptions/systems_controller_spec.rb +++ b/engines/scc_proxy/spec/requests/api/connect/v3/subscriptions/systems_controller_spec.rb @@ -17,6 +17,90 @@ let(:params) do { hostname: 'test', + proxy_byos_mode: :byos, + instance_data: instance_data, + hwinfo: + { + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'super_cloud' + } + } + end + + context 'valid credentials' do + before do + stub_request(:post, scc_register_system_url) + .to_return( + status: 201, + body: scc_register_response.to_s, + headers: {} + ) + end + + it 'saves the data' do + post '/connect/subscriptions/systems', params: params, headers: { HTTP_AUTHORIZATION: 'Token token=bar' } + system = System.find_by(login: 'SCC_foo') + expect(system.instance_data).to eq(instance_data) + end + end + + context 'credentials not found' do + before do + stub_request(:post, scc_register_system_url) + .to_return( + status: [401, 'Unauthorized'], + body: '{}', + headers: {} + ) + end + + it 'returns error' do + post '/connect/subscriptions/systems', params: params, headers: { HTTP_AUTHORIZATION: 'Token token=bar' } + data = JSON.parse(response.body) + expect(response.code).to eq('401') + expect(data['type']).to eq('error') + expect(data['error']).to include('Unauthorized') + end + end + + context 'unreachable server' do + before do + stub_request(:post, scc_register_system_url) + .to_return( + status: 408, + body: scc_register_response.to_s, + headers: {} + ) + end + + it 'returns error' do + post '/connect/subscriptions/systems', params: params, headers: { HTTP_AUTHORIZATION: 'Token token=bar' } + data = JSON.parse(response.body) + expect(data['type']).to eq('error') + expect(data['error']).to eq('408 ""') + end + end + end + + context 'using SCC generated credentials (PAYG/LTSS mode)' do + let(:scc_register_system_url) { 'https://scc.suse.com/connect/subscriptions/systems' } + let(:scc_register_response) do + { + id: 5684096, + login: 'SCC_foo', + password: '1234', + last_seen_at: '2021-10-24T09:48:52.658Z' + }.to_json + end + let(:params) do + { + hostname: 'test', + proxy_byos_mode: :payg, instance_data: instance_data, hwinfo: { diff --git a/engines/scc_proxy/spec/requests/api/connect/v3/systems/products_controller_spec.rb b/engines/scc_proxy/spec/requests/api/connect/v3/systems/products_controller_spec.rb index b0ef27a1f..f023a15b9 100644 --- a/engines/scc_proxy/spec/requests/api/connect/v3/systems/products_controller_spec.rb +++ b/engines/scc_proxy/spec/requests/api/connect/v3/systems/products_controller_spec.rb @@ -3,307 +3,613 @@ # rubocop:disable RSpec/NestedGroups describe Api::Connect::V3::Systems::ProductsController, type: :request do - include_context 'auth header', :system, :login, :password - include_context 'version header', 3 - let(:url) { connect_systems_products_url } - let(:headers) { auth_header.merge(version_header) } - let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions) } let(:product_sap) { FactoryBot.create(:product, :product_sles_sap, :with_mirrored_repositories, :with_mirrored_extensions) } - - let(:payload) do - { - identifier: product.identifier, - version: product.version, - arch: product.arch - } - end - let(:payload_byos) do - { - identifier: product.identifier, - version: product.version, - arch: product.arch, - email: 'foo', - token: 'bar' - } - end + let(:instance_data) { '' } + let(:scc_register_system_url) { 'https://scc.suse.com/connect/subscriptions/systems' } + let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } + let(:scc_systems_url) { 'https://scc.suse.com/connect/systems' } describe '#activate' do let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } - context 'when system has hw_info' do - let(:instance_data) { '{"instanceId": "dummy_instance_data"}' } - let(:new_system_token) { 'BBBBBBBB-BBBB-4BBB-9BBB-BBBBBBBBBBBB' } - let(:system) { FactoryBot.create(:system, :with_system_information, instance_data: instance_data) } - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end - - let(:serialized_service_sap_json) do - V3::ServiceSerializer.new( - product_sap.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end - - context 'when system is connected to SCC' do - let(:system) do - FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data, - system_token: new_system_token) - end - let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } - let(:subscription_response) do + context 'when system is BYOS' do + include_context 'auth header', :system_byos, :login, :password + include_context 'version header', 3 + let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions) } + let(:headers) { auth_header.merge(version_header) } + + let(:payload_byos) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + email: 'foo', + token: 'bar', + byos_mode: 'byos', + hwinfo: { - id: 4206714, - regcode: 'bar', - name: 'SUSE Employee subscription for SUSE Linux Enterprise Server for SAP Applications', - type: 'internal', - status: 'ACTIVE', - starts_at: '2019-03-20T09:48:52.658Z', - expires_at: '2024-03-20T09:48:52.658Z', - system_limit: '100', - systems_count: '156', - virtual_count: nil, - product_classes: [ - 'AiO', - '7261', - 'SLE-HAE-X86', - '7261-BETA', - 'SLE-HAE-X86-BETA', - 'AiO-BETA', - '7261-ALPHA', - 'SLE-HAE-X86-ALPHA', - 'AiO-ALPHA' - ], - product_ids: [ - 1959, - 1421 - ], - skus: [], - systems: [ - { - id: 3021957, - login: 'SCC_foo', - password: '5ee7273ac6ac4d7f', - last_seen_at: '2019-03-20T14:01:05.424Z' - } - ] + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' } - end - - before do - allow(plugin_double).to( - receive(:instance_valid?) - .and_raise(InstanceVerification::Exception, 'Custom plugin error') - ) - end - - context 'with a valid registration code' do - before do - stub_request(:post, scc_activate_url) - .to_return( - status: 201, - body: '{"id": "bar"}', - headers: {} - ) - allow(File).to receive(:directory?) - allow(FileUtils).to receive(:mkdir_p) - allow(FileUtils).to receive(:touch) - - allow(InstanceVerification).to receive(:write_cache_file).twice.with('repo/cache', "127.0.0.1-#{system.login}-#{product.id}") - allow(InstanceVerification).to receive(:write_cache_file).twice.with('registry/cache', "127.0.0.1-#{system.login}") - end + } + end - it 'renders service JSON' do - post url, params: payload_byos, headers: headers - expect(response.body).to eq(serialized_service_json) - end + context 'when system has hw_info' do + let(:instance_data) { '{"instanceId": "dummy_instance_data"}' } + let(:new_system_token) { 'BBBBBBBB-BBBB-4BBB-9BBB-BBBBBBBBBBBB' } + let(:system_byos) { FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data) } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - context 'with a not valid registration code' do - before do - stub_request(:post, scc_activate_url) - .to_return( - status: 401, - body: '{"error": "No product found on SCC for: foo bar x86_64 json api"}', - headers: {} - ) - allow(InstanceVerification).to receive(:write_cache_file).twice.with('repo/cache', "127.0.0.1-#{system.login}-#{product.id}") - allow(InstanceVerification).to receive(:write_cache_file).twice.with('registry/cache', "127.0.0.1-#{system.login}") - allow(FileUtils).to receive(:mkdir_p) - allow(FileUtils).to receive(:touch) - - post url, params: payload_byos, headers: headers - end - - it 'renders an error with exception details' do - data = JSON.parse(response.body) - expect(data['error']).to include('No product found on SCC') - expect(data['error']).not_to include('json api') - end + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - context 'with different system_tokens' do - let(:system2) do + context 'when system is connected to SCC' do + let(:system_byos) do FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data, - system_token: 'foo') + system_token: new_system_token) + end + let(:subscription_response) do + { + id: 4206714, + regcode: 'bar', + name: 'SUSE Employee subscription for SUSE Linux Enterprise Server for SAP Applications', + type: 'internal', + status: 'ACTIVE', + starts_at: '2019-03-20T09:48:52.658Z', + expires_at: '2024-03-20T09:48:52.658Z', + system_limit: '100', + systems_count: '156', + virtual_count: nil, + product_classes: [ + 'AiO', + '7261', + 'SLE-HAE-X86', + '7261-BETA', + 'SLE-HAE-X86-BETA', + 'AiO-BETA', + '7261-ALPHA', + 'SLE-HAE-X86-ALPHA', + 'AiO-ALPHA' + ], + product_ids: [ + 1959, + 1421 + ], + skus: [], + systems: [ + { + id: 3021957, + login: 'SCC_foo', + password: '5ee7273ac6ac4d7f', + last_seen_at: '2019-03-20T14:01:05.424Z' + } + ] + } end before do - allow(System).to receive(:get_by_credentials).and_return([system, system2]) allow(plugin_double).to( receive(:instance_valid?) .and_raise(InstanceVerification::Exception, 'Custom plugin error') ) - stub_request(:post, scc_activate_url) - .to_return( - status: 201, - body: '{"id": "bar"}', - headers: {} + end + + context 'with a valid registration code' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: { id: 'bar' }.to_json, + headers: {} + ) + allow(File).to receive(:directory?) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_byos.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_byos.login}" ) - allow(InstanceVerification).to receive(:write_cache_file).twice.with('repo/cache', "127.0.0.1-#{system.login}-#{product.id}") - allow(InstanceVerification).to receive(:write_cache_file).twice.with('registry/cache', "127.0.0.1-#{system.login}") - allow(File).to receive(:directory?) - allow(FileUtils).to receive(:mkdir_p) - allow(FileUtils).to receive(:touch) + end - post url, params: payload_byos, headers: headers + it 'renders service JSON' do + post url, params: payload_byos, headers: headers + expect(response.body).to eq(serialized_service_json) + end end - it 'renders service JSON' do - expect(response.body).to eq(serialized_service_json) - end - end + context 'with a not valid registration code' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 401, + body: { error: 'No product found on SCC for: foo bar x86_64 json api' }.to_json, + headers: {} + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_byos.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_byos.login}" + ) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) - context 'with duplicated system_tokens' do - let(:system3) do - FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data, - system_token: 'foo') + post url, params: payload_byos, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to include('No product found on SCC') + expect(data['error']).not_to include('json api') + end end - before do - system3 = system - system3.save! - allow(System).to receive(:get_by_credentials).and_return([system, system3]) - allow(plugin_double).to( - receive(:instance_valid?) - .and_raise(InstanceVerification::Exception, 'Custom plugin error') - ) - headers['System-Token'] = 'foo' - stub_request(:post, scc_activate_url) - .to_return( - status: 201, - body: '{"id": "bar"}', - headers: {} + context 'with different system_tokens' do + let(:system_byos2) do + FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data, + system_token: 'foo') + end + + before do + allow(System).to receive(:get_by_credentials).and_return([system_byos, system_byos2]) + allow(plugin_double).to( + receive(:instance_valid?) + .and_raise(InstanceVerification::Exception, 'Custom plugin error') + ) + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: { id: 'bar' }.to_json, + headers: {} + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_byos.login}-#{product.id}" ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_byos.login}" + ) + allow(File).to receive(:directory?) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) - allow(InstanceVerification).to receive(:write_cache_file).twice.with('repo/cache', "127.0.0.1-#{system.login}-#{product.id}") - allow(InstanceVerification).to receive(:write_cache_file).twice.with('registry/cache', "127.0.0.1-#{system.login}") - allow(File).to receive(:directory?) - allow(FileUtils).to receive(:mkdir_p) - allow(FileUtils).to receive(:touch) + post url, params: payload_byos, headers: headers + end - post url, params: payload_byos, headers: headers + it 'renders service JSON' do + expect(response.body).to eq(serialized_service_json) + end end - it 'renders service JSON' do - expect(response.body).to eq(serialized_service_json) + context 'with duplicated system_tokens' do + let(:system_byos3) do + FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data, + system_token: 'foo') + end + + before do + system_byos3 = system_byos + system_byos3.save! + allow(System).to receive(:get_by_credentials).and_return([system_byos, system_byos3]) + allow(plugin_double).to( + receive(:instance_valid?) + .and_raise(InstanceVerification::Exception, 'Custom plugin error') + ) + headers['System-Token'] = 'foo' + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: { id: 'bar' }.to_json, + headers: {} + ) + + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_byos.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_byos.login}" + ) + allow(File).to receive(:directory?) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + + post url, params: payload_byos, headers: headers + end + + it 'renders service JSON' do + expect(response.body).to eq(serialized_service_json) + end end end end end - end - context 'when activating extensions' do - let(:instance_data) { 'dummy_instance_data' } - let(:system) do - FactoryBot.create( - :system, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data - ) - end - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end - let(:scc_activate_url) { 'https://scc.suse.com/connect/systems/products' } - - before do - FactoryBot.create(:subscription, product_classes: product_classes) - stub_request(:post, scc_activate_url) - .to_return( - status: 401, - body: 'bar', - headers: {} + context 'when activating extensions for BYOS' do + let(:instance_data) { 'dummy_instance_data' } + let(:system_byos) do + FactoryBot.create( + :system, :byos, :with_system_information, :with_activated_product, product: base_product, instance_data: instance_data ) + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:payload_byos) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + email: 'foo', + token: 'bar', + byos_mode: 'byos', + hwinfo: + { + hostname: 'test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' + } + } + end - post url, params: payload, headers: headers - end + before do + allow(InstanceVerification::Providers::Example).to receive(:new) + .with(nil, nil, nil, 'dummy_instance_data').and_return(plugin_double) + allow(plugin_double).to receive(:parse_instance_data).and_return({ InstanceId: 'foo' }) + FactoryBot.create(:subscription, product_classes: product_classes) + stub_request(:post, scc_activate_url) + .to_return( + status: 401, + body: { error: 'Instance verification failed: The product is not available for this instance' }.to_json, + headers: {} + ) + # stub the fake announcement call PAYG has to do to SCC + # to create the system before activate product (and skip validation) + stub_request(:post, scc_register_system_url) + .to_return(status: 201, body: { ok: 'OK' }.to_json, headers: {}) - context 'when the extension is not free' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } + post url, params: payload_byos, headers: headers + end + end - context 'when a suitable subscription is not found' do - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] + context 'when system is PAYG' do + include_context 'auth header', :system_payg, :login, :password + include_context 'version header', 3 + let(:headers) { auth_header.merge(version_header) } + let(:system_payg) { FactoryBot.create(:system, :payg, :with_system_information, :with_activated_base_product, instance_data: instance_data) } + let(:product) do + FactoryBot.create( + :product, :product_sles, :extension, :with_mirrored_repositories, :with_mirrored_extensions, + base_products: [system_payg.products.first] ) - end - let(:product_classes) { [base_product.product_class] } + end + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + instance_data: instance_data, + token: 'bar', + byos_mode: 'hybrid', + hwinfo: + { + hostname: 'super_test', + cpus: '1', + sockets: '1', + hypervisor: 'Xen', + arch: 'x86_64', + uuid: 'ec235f7d-b435-e27d-86c6-c8fef3180a01', + cloud_provider: 'amazon' + } + } + end - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Instance verification failed: The product is not available for this instance') - expect(InstanceVerification::Providers::Example).not_to receive(:new) + context 'when system has hw_info' do + let(:instance_data) { '{"instanceId": "dummy_instance_data"}' } + let(:new_system_token) { 'BBBBBBBB-BBBB-4BBB-9BBB-BBBBBBBBBBBB' } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - end - context 'when a suitable subscription is found' do - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] - ) + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json end - let(:product_classes) { [base_product.product_class, product.product_class] } - it 'returns service JSON' do - expect(response.body).to eq(serialized_service_json) + context 'when system is connected to SCC' do + let(:system_payg) do + FactoryBot.create(:system, :payg, :with_system_information, :with_activated_base_product, instance_data: instance_data, + system_token: new_system_token) + end + let(:product) do + FactoryBot.create( + :product, :product_sles_ltss, :extension, :with_mirrored_repositories, :with_mirrored_extensions, + base_products: [system_payg.products.first] + ) + end + let(:subscription_response) do + { + id: 4206714, + regcode: 'bar', + name: 'SUSE Employee subscription for SUSE Linux Enterprise Server for SAP Applications', + type: 'internal', + status: 'ACTIVE', + starts_at: '2019-03-20T09:48:52.658Z', + expires_at: '2024-03-20T09:48:52.658Z', + system_limit: '100', + systems_count: '156', + virtual_count: nil, + product_classes: [ + 'AiO', + '7261', + 'SLE-HAE-X86', + '7261-BETA', + 'SLE-HAE-X86-BETA', + 'AiO-BETA', + '7261-ALPHA', + 'SLE-HAE-X86-ALPHA', + 'AiO-ALPHA' + ], + product_ids: [ + 1959, + 1421 + ], + skus: [], + systems: [ + { + id: 3021957, + login: 'SCC_foo', + password: '5ee7273ac6ac4d7f', + last_seen_at: '2019-03-20T14:01:05.424Z' + } + ] + } + end + + before do + allow(plugin_double).to( + receive(:instance_valid?) + .and_raise(InstanceVerification::Exception, 'Custom plugin error') + ) + end + + context 'with a valid registration code' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: { id: 'bar' }.to_json, + headers: {} + ) + allow(File).to receive(:directory?) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + allow(InstanceVerification::Providers::Example).to receive(:new).and_return(plugin_double) + allow(plugin_double).to receive(:allowed_extension?).and_return(true) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_payg.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_payg.login}" + ) + allow(plugin_double).to receive(:instance_valid?).and_return(true) + end + + context 'when LTSS not allowed' do + before do + allow(plugin_double).to receive(:allowed_extension?).and_return(false) + end + + it 'raises an error' do + stub_request(:post, scc_register_system_url) + .to_return(status: 403, body: { ok: 'OK' }.to_json, headers: {}) + + post url, params: payload, headers: headers + data = JSON.parse(response.body) + expect(data['error']).to include('Product not supported for this instance') + end + end + end end end - end - context 'when the extension is free' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: true, base_products: [base_product] - ) - end - let(:product_classes) { [base_product.product_class] } + context 'when system has hw info' do + let(:instance_data) { '{"instanceId": "dummy_instance_data"}' } + let(:new_system_token) { 'BBBBBBBB-BBBB-4BBB-9BBB-BBBBBBBBBBBB' } + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end - it 'returns service JSON' do - expect(response.body).to eq(serialized_service_json) - end - end + let(:serialized_service_sap_json) do + V3::ServiceSerializer.new( + product_sap.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + context 'when system is connected to SCC' do + let(:system_payg) do + FactoryBot.create(:system, :payg, :with_system_information, :with_activated_base_product, instance_data: instance_data, + system_token: new_system_token) + end + let(:product) do + FactoryBot.create( + :product, :product_sles_ltss, :extension, :with_mirrored_repositories, :with_mirrored_extensions, + base_products: [system_payg.products.first] + ) + end + let(:subscription_response) do + { + id: 4206714, + regcode: 'bar', + name: 'SUSE Employee subscription for SUSE Linux Enterprise Server for SAP Applications', + type: 'internal', + status: 'ACTIVE', + starts_at: '2019-03-20T09:48:52.658Z', + expires_at: '2024-03-20T09:48:52.658Z', + system_limit: '100', + systems_count: '156', + virtual_count: nil, + product_classes: [ + 'AiO', + '7261', + 'SLE-HAE-X86', + '7261-BETA', + 'SLE-HAE-X86-BETA', + 'AiO-BETA', + '7261-ALPHA', + 'SLE-HAE-X86-ALPHA', + 'AiO-ALPHA' + ], + product_ids: [ + 1959, + 1421 + ], + skus: [], + systems: [ + { + id: 3021957, + login: 'SCC_foo', + password: '5ee7273ac6ac4d7f', + last_seen_at: '2019-03-20T14:01:05.424Z' + } + ] + } + end - context 'when the base product subscription is missing' do - let(:base_product) { FactoryBot.create(:product, :with_mirrored_repositories) } - let(:product) do - FactoryBot.create( - :product, :with_mirrored_repositories, :extension, free: false, base_products: [base_product] - ) - end - let(:product_classes) { [] } + before do + allow(plugin_double).to( + receive(:instance_valid?) + .and_raise(InstanceVerification::Exception, 'Custom plugin error') + ) + end - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Unexpected instance verification error has occurred') + context 'with a valid registration code' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 201, + body: { id: 'bar' }.to_json, + headers: {} + ) + allow(File).to receive(:directory?) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_payg.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_payg.login}" + ) + allow(plugin_double).to receive(:instance_valid?).and_return(true) + end + + it 'renders service JSON' do + stub_request(:post, scc_register_system_url) + .to_return(status: 201, body: { ok: 'OK' }.to_json, headers: {}) + + post url, params: payload, headers: headers + expect(response.body).to eq(serialized_service_json) + end + end + + context 'with a not valid registration code' do + before do + stub_request(:post, scc_activate_url) + .to_return( + status: 401, + body: { error: 'No product found on SCC for: foo bar x86_64 json api' }.to_json, + headers: {} + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_payg.login}-#{product.id}" + ) + allow(InstanceVerification).to receive(:write_cache_file).twice.with( + Rails.application.config.registry_cache_dir, "127.0.0.1-#{system_payg.login}" + ) + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + end + + context 'when de-register system from SCC suceeds' do + before do + stub_request(:delete, scc_systems_url) + .to_return( + status: 204, + body: '', + headers: {} + ) + + stub_request(:post, scc_register_system_url) + .to_return(status: 201, body: { ok: 'OK' }.to_json, headers: {}) + post url, params: payload, headers: headers + end + + it 'renders an error with exception details' do + data = JSON.parse(response.body) + expect(data['error']).to include('No product found on SCC') + expect(data['error']).not_to include('json api') + end + end + + context 'when de-register system from SCC fails' do + let(:error_message) { "Could not activate #{product.product_string}, error: No product found on SCC for: foo bar x86_64 json api 401" } + + before do + allow(Rails.logger).to receive(:info) + stub_request(:delete, scc_systems_url) + .to_return( + status: 401, + body: { error: 'Could not de-register system' }.to_json, + headers: {} + ) + stub_request(:post, scc_register_system_url) + .to_return(status: 201, body: { ok: 'OK' }.to_json, headers: {}) + end + + it 'renders an error with exception details' do + expect(Rails.logger).to receive(:info).with(error_message) + post url, params: payload, headers: headers + data = JSON.parse(response.body) + expect(data['error']).to eq('Could not de-register system') + end + end + end + end end end end diff --git a/engines/scc_proxy/spec/requests/api/connect/v4/systems/products_controller_spec.rb b/engines/scc_proxy/spec/requests/api/connect/v4/systems/products_controller_spec.rb index 73cafc798..8c1801c33 100644 --- a/engines/scc_proxy/spec/requests/api/connect/v4/systems/products_controller_spec.rb +++ b/engines/scc_proxy/spec/requests/api/connect/v4/systems/products_controller_spec.rb @@ -1,21 +1,97 @@ describe Api::Connect::V4::Systems::ProductsController, type: :request do - include_context 'auth header', :system, :login, :password - include_context 'version header', 4 - - let(:url) { connect_systems_products_url } let(:headers) { auth_header.merge(version_header) } let(:instance_data) { 'dummy_instance_data' } - let(:system) { FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data) } describe '#deactivate' do - let(:scc_systems_products_url) { 'https://scc.suse.com/connect/systems/products' } + context 'when system is byos' do + include_context 'auth header', :system_byos, :login, :password + include_context 'version header', 4 + let(:scc_systems_products_url) { 'https://scc.suse.com/connect/systems/products' } + let(:system_byos) { FactoryBot.create(:system, :byos, :with_system_information, instance_data: instance_data) } + let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } + + # rubocop:disable RSpec/NestedGroups + context 'an activated non base module' do + context 'with right credentials' do + let(:product) do + FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_byos, +product_type: 'module') + end + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch + } + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end - context 'an activated non base module' do - context 'with right credentials' do - let(:product) do - FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system, product_type: 'module') + before do + stub_request(:delete, scc_systems_products_url) + .to_return( + status: 200, + body: '', + headers: {} + ) + allow(Rails.logger).to receive(:info) + delete url, params: payload, headers: headers + end + + it 'returns a service JSON and successfully deactivate the product' do + expect(Rails.logger).to( + have_received(:info).with( + "Product '#{product.friendly_name}' successfully deactivated from SCC" + ).once + ) + expect(response.body).to eq(serialized_service_json) + end end + + context 'when SCC API returns an error' do + let(:product) do + FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_byos, +product_type: 'module') + end + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch + } + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + before do + stub_request(:delete, scc_systems_products_url) + .to_return( + status: 422, + body: "{\"error\": \"Could not de-activate product \'#{product.friendly_name}\'\"}", + headers: {} + ) + delete url, params: payload, headers: headers + end + + it 'reports an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq('Could not de-activate product \'SUSE Linux Enterprise Server 15 SP3 x86_64\'') + end + end + end + # rubocop:enable RSpec/NestedGroups + + context 'an activated base module with right credentials' do + let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_byos) } let(:payload) do { identifier: product.identifier, @@ -23,38 +99,234 @@ arch: product.arch } end - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json + + before { delete url, params: payload, headers: headers } + + it 'reports an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq("The product \"#{product.name}\" is a base product and cannot be deactivated") end + end + end - before do - stub_request(:delete, scc_systems_products_url) - .to_return( - status: 200, - body: '', - headers: {} - ) - allow(Rails.logger).to receive(:info) - delete url, params: payload, headers: headers + # rubocop:disable RSpec/NestedGroups + context 'when system is hybrid' do + include_context 'auth header', :system_hybrid, :login, :password + include_context 'version header', 4 + let(:scc_systems_products_url) { 'https://scc.suse.com/connect/systems/products' } + let(:system_hybrid) { FactoryBot.create(:system, :hybrid, :with_system_information, instance_data: instance_data) } + + context 'an activated non base module' do + context 'with right credentials' do + let(:product) do + FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_hybrid, +product_type: 'module') + end + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch + } + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + before do + stub_request(:delete, scc_systems_products_url) + .to_return( + status: 200, + body: '', + headers: {} + ) + allow(Rails.logger).to receive(:info) + delete url, params: payload, headers: headers + end + + it 'returns a service JSON and successfully deactivate the product' do + expect(Rails.logger).to( + have_received(:info).with( + "Product '#{product.friendly_name}' successfully deactivated from SCC" + ).once + ) + expect(response.body).to eq(serialized_service_json) + end + end + + context 'when SCC API for activations returns an error' do + let(:product) do + FactoryBot.create(:product, :product_sles, :extension, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_hybrid) + end + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch + } + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } + + before do + stub_request(:get, scc_systems_activations_url).to_return(status: 401, body: "{\"error\": \"Error\'\"}", headers: {}) + delete url, params: payload, headers: headers + end + + it 'reports an error' do + data = JSON.parse(response.body) + expect(data['error']).to eq("{\"error\": \"Error'\"}") + end end - it 'returns a service JSON and successfully deactivate the product' do - expect(Rails.logger).to( - have_received(:info).with( - "Product '#{product.friendly_name}' successfully deactivated from SCC" - ).once - ) - expect(response.body).to eq(serialized_service_json) + context 'when SCC API suceeds for HYBRID system' do + let(:product) do + FactoryBot.create(:product, :product_sles, :extension, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_hybrid) + end + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch + } + end + let(:serialized_service_json) do + V3::ServiceSerializer.new( + product.service, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } + let(:scc_systems_url) { 'https://scc.suse.com/connect/systems' } + + before do + stub_request(:delete, scc_systems_products_url) + .to_return( + status: 200, + body: "{\"error\": \"Could not de-activate product \'#{product.friendly_name}\'\"}", + headers: {} + ) + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: body_active, headers: {}) + end + + context 'when only one product was active' do + let(:body_active) do + [{ + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'ACTIVE', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today + 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: system_hybrid.activations.first.product.product_class + } + } + }].to_json + end + + context 'when deactivating the system succeeds' do + before do + stub_request(:delete, scc_systems_url).to_return(status: 204, body: '', headers: {}) + delete url, params: payload, headers: headers + end + + it 'makes the hybrid system payg' do + updated_system = System.find_by(login: system_hybrid.login) + expect(updated_system.payg?).to eq(true) + end + end + + context 'when deactivating the system fails' do + before do + allow(Rails.logger).to receive(:info) + stub_request(:delete, scc_systems_url).to_return( + status: 422, + body: '{"error": "Oh oh, something went wrong"}', + headers: {} + ) + delete url, params: payload, headers: headers + end + + it 'makes the hybrid system payg' do + expect(Rails.logger).to( + have_received(:info).with( + "Could not de-activate system #{system_hybrid.login}, error: Oh oh, something went wrong 422" + ).once + ) + data = JSON.parse(response.body) + expect(data['error']).to eq('Oh oh, something went wrong') + end + end + end + + context 'when more activations are left' do + let(:body_active) do + [ + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'ACTIVE', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today + 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: system_hybrid.activations.first.product.product_class + } + } + }, { + id: 2, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'ACTIVE', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today + 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: '30', + product_class: '23' + } + } + } + ].to_json + end + + before do + stub_request(:delete, scc_systems_url).to_return(status: 204, body: '', headers: {}) + delete url, params: payload, headers: headers + end + + it 'keeps the system as hybrid' do + updated_system = System.find_by(login: system_hybrid.login) + expect(updated_system.hybrid?).to eq(true) + end + end end end - context 'when SCC API returns an error' do - let(:product) do - FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system, product_type: 'module') - end + context 'an activated base module with right credentials' do + let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system_hybrid) } let(:payload) do { identifier: product.identifier, @@ -62,46 +334,15 @@ arch: product.arch } end - let(:serialized_service_json) do - V3::ServiceSerializer.new( - product.service, - base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s - ).to_json - end - before do - stub_request(:delete, scc_systems_products_url) - .to_return( - status: 422, - body: "{\"error\": \"Could not de-activate product \'#{product.friendly_name}\'\"}", - headers: {} - ) - delete url, params: payload, headers: headers - end + before { delete url, params: payload, headers: headers } it 'reports an error' do data = JSON.parse(response.body) - expect(data['error']).to eq('Could not de-activate product \'SUSE Linux Enterprise Server 15 SP3 x86_64\'') + expect(data['error']).to eq("The product \"#{product.name}\" is a base product and cannot be deactivated") end end end - - context 'an activated base module with right credentials' do - let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories, :with_mirrored_extensions, :activated, system: system) } - let(:payload) do - { - identifier: product.identifier, - version: product.version, - arch: product.arch - } - end - - before { delete url, params: payload, headers: headers } - - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq("The product \"#{product.name}\" is a base product and cannot be deactivated") - end - end + # rubocop:enable RSpec/NestedGroups end end diff --git a/engines/scc_suma_api/app/controllers/scc_suma_api/scc_suma_api_controller.rb b/engines/scc_suma_api/app/controllers/scc_suma_api/scc_suma_api_controller.rb index a7e67cc30..879fc7c06 100644 --- a/engines/scc_suma_api/app/controllers/scc_suma_api/scc_suma_api_controller.rb +++ b/engines/scc_suma_api/app/controllers/scc_suma_api/scc_suma_api_controller.rb @@ -48,7 +48,9 @@ def is_valid? ) # check auth for registered BYOS systems iid = verification_provider.parse_instance_data - systems_found = System.find_by(system_token: iid['instanceId'], proxy_byos: true) + # at this point, we do not know nor is available the login information of the system + # so querying the instance ID, which is a unique value, to fetch the system + systems_found = System.find_by(system_token: iid['instanceId'], proxy_byos_mode: :byos) raise 'Unspecified error' unless systems_found.present? || verification_provider.instance_valid? end diff --git a/engines/scc_suma_api/spec/requests/scc_suma_api/scc_suma_api_controller_spec.rb b/engines/scc_suma_api/spec/requests/scc_suma_api/scc_suma_api_controller_spec.rb index 0013320c6..bf3a9c4b1 100644 --- a/engines/scc_suma_api/spec/requests/scc_suma_api/scc_suma_api_controller_spec.rb +++ b/engines/scc_suma_api/spec/requests/scc_suma_api/scc_suma_api_controller_spec.rb @@ -67,7 +67,7 @@ module SccSumaApi allow_any_instance_of(InstanceVerification::Providers::Example).to( receive(:instance_valid?).and_return(false) ) - System.stub(:find_by).and_return('foo') + allow(System).to receive(:find_by).and_return 'foo' allow(SUSE::Connect::Api).to receive(:new).and_return api_double allow(api_double).to receive(:list_products_unscoped).and_return products diff --git a/engines/strict_authentication/app/controllers/strict_authentication/authentication_controller.rb b/engines/strict_authentication/app/controllers/strict_authentication/authentication_controller.rb index 311c38fc0..b3fabfab6 100644 --- a/engines/strict_authentication/app/controllers/strict_authentication/authentication_controller.rb +++ b/engines/strict_authentication/app/controllers/strict_authentication/authentication_controller.rb @@ -7,27 +7,30 @@ class AuthenticationController < ::ApplicationController # This is the endpoint for nginx subrequest auth check def check request_uri = request.headers['X-Original-URI'] - auth_result = path_allowed?(request.headers['X-Original-URI']) + auth_result = path_allowed?(request.headers) logger.info "Authentication subrequest for #{request_uri} -- #{auth_result ? 'allowed' : 'denied'}" head auth_result ? :ok : :forbidden end protected - def path_allowed?(path) + def path_allowed?(headers) + path = headers['X-Original-URI'] return false if path.blank? + return true if path =~ %r{/product\.license/} path = '/' + path.gsub(/^#{RMT::DEFAULT_MIRROR_URL_PREFIX}/, '') - # Allow access to SLES 12 and 12-SP1 repos for systems migrating from SLES 11 has_sles11 = @system.products.where(identifier: 'SUSE_SLES').first return true if (has_sles11 && (path =~ %r{/12/} || path =~ %r{/12-SP1/})) - all_allowed_paths.find { |allowed_path| path =~ /^#{Regexp.escape(allowed_path)}/ } + all_allowed_paths(headers).find { |allowed_path| path =~ /^#{Regexp.escape(allowed_path)}/ } end - def all_allowed_paths + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def all_allowed_paths(headers) # return all versions of the same product and arch # (that the system has available with that subscription) # in order to validate access not only for current product but others @@ -36,10 +39,31 @@ def all_allowed_paths # to them or verify paths all_product_versions = @system.products.map { |p| Product.where(identifier: p.identifier, arch: p.arch) }.flatten allowed_paths = all_product_versions.map { |prod| prod.repositories.pluck(:local_path) }.flatten + # Allow SLE Micro to access all free SLES repositories + sle_micro = @system.products.any? { |p| p.identifier.downcase.include?('sle-micro') } + if sle_micro + system_products_archs = @system.products.pluck(:arch) + product_free_sles_modules_only = Product.where( + "(lower(identifier) like 'sle-module%' or lower(identifier) like 'packagehub') + and lower(identifier) not like '%sap%' + and arch = '#{system_products_archs.first}' + and free = 1" + ) + end + same_arch = product_free_sles_modules_only.any? { |p| system_products_archs.include?(p.arch) } if product_free_sles_modules_only.present? + allowed_paths += product_free_sles_modules_only.map { |prod| prod.repositories.pluck(:local_path) }.flatten if same_arch + # for the SUMa PAYG offers, RMT access verification code allows access # to the SUMa Client Tools channels and SUMa Proxy channels # when product is SUMA_Server and PAYG or SUMA_Server and used as SCC proxy - manager_prod = @system.products.any? { |p| p.identifier.downcase.include?('manager-server') } + manager_prod = @system.products.any? do |p| + manager = p.identifier.downcase.include?('manager-server') + # SUMA 5.0 must have access to SUMA 4.3, 4.2 and so on + micro = p.identifier.downcase.include?('sle-micro') + instance_id_header = headers.fetch('X-Instance-Identifier', '').casecmp('suse-manager-server').zero? + instance_version_header = headers.fetch('X-Instance-Version', '0').split('.')[0] >= '5' + manager || (micro && instance_id_header && instance_version_header) + end if manager_prod # add all SUMA products paths @@ -49,5 +73,7 @@ def all_allowed_paths end allowed_paths end + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity end end diff --git a/engines/strict_authentication/spec/requests/strict_authentication/authentication_controller_spec.rb b/engines/strict_authentication/spec/requests/strict_authentication/authentication_controller_spec.rb index c921d3855..fcb5f4e03 100644 --- a/engines/strict_authentication/spec/requests/strict_authentication/authentication_controller_spec.rb +++ b/engines/strict_authentication/spec/requests/strict_authentication/authentication_controller_spec.rb @@ -5,7 +5,7 @@ module StrictAuthentication RSpec.describe AuthenticationController, type: :request do subject { response } - let(:system) { FactoryBot.create(:system, :with_activated_product) } + let(:system) { FactoryBot.create(:system, :with_activated_product_sle_micro) } describe '#check' do context 'without authentication' do @@ -39,6 +39,36 @@ module StrictAuthentication its(:code) { is_expected.to eq '403' } end + context 'when requesting a file in an activated SLES repo on a SLE Micro system' do + let(:free_product) do + prod = FactoryBot.create( + :product, :module, :with_mirrored_repositories + ) + prod.identifier = 'sle-module-foo' + prod.arch = system.products.first.arch + prod.save! + prod + end + let(:requested_uri) { '/repo' + free_product.repositories.first[:local_path] + '/repodata/repomd.xml' } + + its(:code) { is_expected.to eq '200' } + end + + context 'when requesting a file in an activated SLES SAP repo on a SLE Micro system' do + let(:free_product) do + prod = FactoryBot.create( + :product, :module, :with_mirrored_repositories + ) + prod.identifier = 'sle-module-foo-sap' + prod.arch = system.products.first.arch + prod.save! + prod + end + let(:requested_uri) { '/repo' + free_product.repositories.first[:local_path] + '/repodata/repomd.xml' } + + its(:code) { is_expected.to eq '403' } + end + context 'when requesting a file in an activated repo' do let(:requested_uri) { '/repo' + system.repositories.first[:local_path] + '/repodata/repomd.xml' } diff --git a/engines/zypper_auth/lib/zypper_auth/engine.rb b/engines/zypper_auth/lib/zypper_auth/engine.rb index 1c87a5de6..8478ee7c1 100644 --- a/engines/zypper_auth/lib/zypper_auth/engine.rb +++ b/engines/zypper_auth/lib/zypper_auth/engine.rb @@ -6,7 +6,7 @@ def auth_logger Thread.current[:logger] end - def verify_instance(request, logger, system) + def verify_instance(request, logger, system, params_product_id = nil) return false unless request.headers['X-Instance-Data'] instance_data = Base64.decode64(request.headers['X-Instance-Data'].to_s) @@ -31,31 +31,15 @@ def verify_instance(request, logger, system) ) is_valid = verification_provider.instance_valid? + return false if is_valid && system.hybrid? && !handle_scc_subscription(request, system, verification_provider, params_product_id) + # update repository and registry cache InstanceVerification.update_cache(request.remote_ip, system.login, base_product.id) is_valid rescue InstanceVerification::Exception => e - message = '' - if system.proxy_byos - result = SccProxy.scc_check_subscription_expiration(request.headers, system.login, system.system_token, logger) - if result[:is_active] - InstanceVerification.update_cache(request.remote_ip, system.login, base_product.id) - return true - end - - message = result[:message] - else - message = e.message - end - details = [ "System login: #{system.login}", "IP: #{request.remote_ip}" ] - details << "Instance ID: #{verification_provider.instance_id}" if verification_provider.instance_id - details << "Billing info: #{verification_provider.instance_billing_info}" if verification_provider.instance_billing_info - - ZypperAuth.auth_logger.info <<~LOGMSG - Access to the repos denied: #{message} - #{details.join(', ')} - LOGMSG + return handle_scc_subscription(request, system, verification_provider) if system.byos? + ZypperAuth.zypper_auth_message(request, system, verification_provider, e.message) false rescue StandardError => e logger.error('Unexpected instance verification error has occurred:') @@ -65,6 +49,26 @@ def verify_instance(request, logger, system) logger.error(e.backtrace) false end + + def handle_scc_subscription(request, system, verification_provider, params_product_id = nil) + product_class = Product.find_by(id: params_product_id).product_class if params_product_id.present? + result = SccProxy.scc_check_subscription_expiration(request.headers, system, product_class) + return true if result[:is_active] + + ZypperAuth.zypper_auth_message(request, system, verification_provider, result[:message]) + false + end + + def zypper_auth_message(request, system, verification_provider, message) + details = [ "System login: #{system.login}", "IP: #{request.remote_ip}" ] + details << "Instance ID: #{verification_provider.instance_id}" if verification_provider.instance_id + details << "Billing info: #{verification_provider.instance_billing_info}" if verification_provider.instance_billing_info + + ZypperAuth.auth_logger.info <<~LOGMSG + Access to the repos denied: #{message} + #{details.join(', ')} + LOGMSG + end end class Engine < ::Rails::Engine @@ -126,7 +130,7 @@ def make_repo_url(base_url, repo_local_path, service_name = nil) # additional validation for zypper service XML controller before_action :verify_instance def verify_instance - unless ZypperAuth.verify_instance(request, logger, @system) + unless ZypperAuth.verify_instance(request, logger, @system, params.fetch('id', nil)) render(xml: { error: 'Instance verification failed' }, status: 403) end end diff --git a/engines/zypper_auth/spec/requests/api/connect/v3/systems/activations_controller_spec.rb b/engines/zypper_auth/spec/requests/api/connect/v3/systems/activations_controller_spec.rb index 892ac28df..10500b262 100644 --- a/engines/zypper_auth/spec/requests/api/connect/v3/systems/activations_controller_spec.rb +++ b/engines/zypper_auth/spec/requests/api/connect/v3/systems/activations_controller_spec.rb @@ -16,7 +16,10 @@ context 'without X-Instance-Data headers or hw_info' do it 'has service URLs with HTTP scheme' do - expect(response.body).to include('Instance verification failed') + data = JSON.parse(response.body) + expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) + expect_any_instance_of(InstanceVerification::Providers::Example).not_to receive(:instance_valid?) + expect(InstanceVerification).not_to receive(:update_cache) end end @@ -24,7 +27,10 @@ let(:system) { FactoryBot.create(:system, :with_activated_product, :with_system_information, instance_data: 'plugin:susecloud') } it 'has service URLs with HTTP scheme' do - expect(response.body).to include('Instance verification failed') + data = JSON.parse(response.body) + expect(data[0]['service']['url']).to match(%r{^plugin:/susecloud}) + expect_any_instance_of(InstanceVerification::Providers::Example).not_to receive(:instance_valid?) + expect(InstanceVerification).not_to receive(:update_cache) end end diff --git a/engines/zypper_auth/spec/requests/api/connect/v3/systems/products_controller_spec.rb b/engines/zypper_auth/spec/requests/api/connect/v3/systems/products_controller_spec.rb index ee610d3f8..0d21e2e51 100644 --- a/engines/zypper_auth/spec/requests/api/connect/v3/systems/products_controller_spec.rb +++ b/engines/zypper_auth/spec/requests/api/connect/v3/systems/products_controller_spec.rb @@ -14,7 +14,8 @@ { identifier: product.identifier.downcase, version: product.version, - arch: product.arch + arch: product.arch, + byos_mode: 'byos' } end diff --git a/engines/zypper_auth/spec/requests/strict_authentication/authentication_controller_spec.rb b/engines/zypper_auth/spec/requests/strict_authentication/authentication_controller_spec.rb index bc7d1d8bf..f0ab69371 100644 --- a/engines/zypper_auth/spec/requests/strict_authentication/authentication_controller_spec.rb +++ b/engines/zypper_auth/spec/requests/strict_authentication/authentication_controller_spec.rb @@ -6,7 +6,11 @@ let(:system) { FactoryBot.create(:system, :with_activated_product) } + after { FileUtils.rm_rf(File.dirname(Rails.application.config.registry_cache_dir)) } + describe '#check' do + before { Thread.current[:logger] = RMT::Logger.new('/dev/null') } + context 'with valid credentials' do include_context 'auth header', :system, :login, :password @@ -130,7 +134,7 @@ context 'when subscription is active' do before do stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_active].to_json, headers: {}) - expect(URI).to receive(:encode_www_form).with({ byos: true }) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'byos' }) allow(File).to receive(:directory?).and_return(true) allow(Dir).to receive(:mkdir) allow(FileUtils).to receive(:touch) @@ -143,7 +147,7 @@ context 'when subscription is expired' do before do stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_expired].to_json, headers: {}) - expect(URI).to receive(:encode_www_form).with({ byos: true }) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'byos' }) get '/api/auth/check', headers: headers end @@ -153,7 +157,7 @@ context 'when product is not activated' do before do stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_not_activated].to_json, headers: {}) - expect(URI).to receive(:encode_www_form).with({ byos: true }) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'byos' }) get '/api/auth/check', headers: headers end @@ -163,7 +167,7 @@ context 'when status from SCC is unknown' do before do stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_unknown_status].to_json, headers: {}) - expect(URI).to receive(:encode_www_form).with({ byos: true }) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'byos' }) allow(File).to receive(:directory?) allow(Dir).to receive(:mkdir) allow(FileUtils).to receive(:touch) @@ -176,7 +180,7 @@ context 'when SCC request fails' do before do stub_request(:get, scc_systems_activations_url).to_return(status: 401, body: [body_expired].to_json, headers: {}) - expect(URI).to receive(:encode_www_form).with({ byos: true }) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'byos' }) allow(File).to receive(:directory?) allow(Dir).to receive(:mkdir) allow(FileUtils).to receive(:touch) @@ -202,6 +206,225 @@ it { is_expected.to have_http_status(200) } end + + context 'system is hybrid' do + include_context 'auth header', :system_hybrid, :login, :password + let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } + let(:system_hybrid) { FactoryBot.create(:system, :hybrid, :with_activated_product) } + let(:requested_uri) { '/repo' + system_hybrid.repositories.first[:local_path] + '/repodata/repomd.xml' } + let(:headers) { auth_header.merge({ 'X-Original-URI': requested_uri, 'X-Instance-Data': 'test' }) } + + before do + Rails.cache.clear + allow(InstanceVerification).to receive(:update_cache) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + end + + context 'when subscription is active' do + let(:body_active) do + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'ACTIVE', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today + 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: system_hybrid.activations.first.product.product_class + '-LTSS' + } + } + } + end + let(:headers) { auth_header } + + before do + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_active].to_json, headers: {}) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'hybrid' }) + allow(File).to receive(:directory?).and_return(true) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + end + + it 'returns true' do + result = SccProxy.scc_check_subscription_expiration( + headers, + system_hybrid, + system_hybrid.activations.first.product.product_class + '-LTSS' + ) + expect(result[:is_active]).to be(true) + end + end + + context 'when subscription is expired' do + let(:body_expired) do + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'EXPIRED', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today - 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: system_hybrid.activations.first.product.product_class + '-LTSS' + } + } + } + end + + before do + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_expired].to_json, headers: {}) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'hybrid' }) + allow(File).to receive(:directory?).and_return(true) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + end + + it 'returns false, expired' do + result = SccProxy.scc_check_subscription_expiration( + headers, + system_hybrid, + system_hybrid.activations.first.product.product_class + '-LTSS' + ) + expect(result[:is_active]).to eq(false) + expect(result[:message]).to eq('Subscription expired.') + end + end + + context 'when product is not activated' do + let(:body_not_activated) do + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'FOO', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today - 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: nil + } + } + } + end + + before do + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_not_activated].to_json, headers: {}) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'hybrid' }) + allow(File).to receive(:directory?).and_return(true) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + end + + it 'returns product not activated' do + result = SccProxy.scc_check_subscription_expiration( + headers, + system_hybrid, + system_hybrid.activations.first.product.product_class + '-LTSS' + ) + expect(result[:is_active]).to eq(false) + expect(result[:message]).to eq('Product not activated.') + end + end + + context 'when unexpected error' do + let(:body_unexpected) do + { + id: 1, + regcode: '631dc51f', + name: 'Subscription 1', + type: 'FULL', + status: 'FOO', + starts_at: 'null', + expires_at: DateTime.parse((Time.zone.today - 1).to_s), + system_limit: 6, + systems_count: 1, + service: { + product: { + id: system_hybrid.activations.first.product.id, + product_class: system_hybrid.activations.first.product.product_class + } + } + } + end + + before do + stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_unexpected].to_json, headers: {}) + expect(URI).to receive(:encode_www_form).with({ byos_mode: 'hybrid' }) + allow(File).to receive(:directory?).and_return(true) + allow(Dir).to receive(:mkdir) + allow(FileUtils).to receive(:touch) + end + + it 'returns unexpected error' do + result = SccProxy.scc_check_subscription_expiration( + headers, + system_hybrid, + system_hybrid.activations.first.product.product_class + '-LTSS' + ) + expect(result[:is_active]).to eq(false) + expect(result[:message]).to eq('Unexpected error when checking product subscription.') + end + end + + context 'regcode check fails' do + let(:error_message) do + "Access to the repos denied: #{scc_response[:message]}\nSystem login: #{system_hybrid.login}, IP: 127.0.0.1\n" + end + + let(:scc_response) do + { + is_active: false, + message: 'You shall not have access to those repos !' + } + end + + before do + expect_any_instance_of(InstanceVerification::Providers::Example).to receive(:instance_valid?).and_return(true) + allow(SccProxy).to receive(:scc_check_subscription_expiration).and_return(scc_response) + expect(SccProxy).to receive(:scc_check_subscription_expiration) + allow(ZypperAuth.auth_logger).to receive(:info) + expect(ZypperAuth.auth_logger).to receive(:info).with(error_message) + expect(FileUtils).not_to receive(:touch) + get '/api/auth/check', headers: headers + end + + it { is_expected.to have_http_status(403) } + end + + context 'regcode check suceeds' do + let(:scc_response) do + { + is_active: true + } + end + + before do + expect_any_instance_of(InstanceVerification::Providers::Example).to receive(:instance_valid?).and_return(true) + allow(SccProxy).to receive(:scc_check_subscription_expiration).and_return(scc_response) + expect(SccProxy).to receive(:scc_check_subscription_expiration) + allow(ZypperAuth.auth_logger).to receive(:info) + expect(ZypperAuth.auth_logger).not_to(receive(:info)) + get '/api/auth/check', headers: headers + end + + it { is_expected.to have_http_status(200) } + end + end end end end diff --git a/lib/rmt.rb b/lib/rmt.rb index 0a90ca963..08a1fa97a 100644 --- a/lib/rmt.rb +++ b/lib/rmt.rb @@ -1,5 +1,5 @@ module RMT - VERSION ||= '2.17'.freeze + VERSION ||= '2.19'.freeze DEFAULT_USER = '_rmt'.freeze DEFAULT_GROUP = 'nginx'.freeze diff --git a/lib/rmt/cli/systems.rb b/lib/rmt/cli/systems.rb index a7655d85c..bc35542a4 100644 --- a/lib/rmt/cli/systems.rb +++ b/lib/rmt/cli/systems.rb @@ -8,12 +8,12 @@ class RMT::CLI::Systems < RMT::CLI::Base desc 'list', _('List registered systems.') option :limit, aliases: '-l', type: :numeric, default: 20, desc: _('Number of systems to display') option :all, aliases: '-a', type: :boolean, desc: _('List all registered systems') - option :proxy_byos, type: :boolean, desc: _('Filter BYOS systems using RMT as a proxy') + option :proxy_byos_mode, type: :boolean, desc: _('Filter BYOS systems using RMT as a proxy') option :csv, type: :boolean, desc: _('Output data in CSV format') def list systems = System.order(id: :desc) - systems = systems.where(proxy_byos: true) if options.proxy_byos + systems = systems.where(proxy_byos_mode: :byos) if options.proxy_byos_mode systems = systems.limit(options.limit) unless options.all if System.count == 0 diff --git a/lib/rmt/config.rb b/lib/rmt/config.rb index 650a316ad..4c7ebe27c 100644 --- a/lib/rmt/config.rb +++ b/lib/rmt/config.rb @@ -1,3 +1,4 @@ +# :nocov: require 'config' require_relative '../rmt' @@ -77,3 +78,4 @@ def validate_int(value) end end end +# :nocov: diff --git a/lib/rmt/gpg.rb b/lib/rmt/gpg.rb index c645348f4..783b04ed1 100644 --- a/lib/rmt/gpg.rb +++ b/lib/rmt/gpg.rb @@ -1,6 +1,7 @@ require 'English' require 'fileutils' require 'tmpdir' +require 'shellwords' class RMT::GPG class RMT::GPG::Exception < RuntimeError @@ -28,7 +29,8 @@ def verify_signature protected def run_import_key - cmd = "gpg --homedir #{@tmpdir} --no-default-keyring --keyring #{@keyring} --import #{@key_file} 2>&1" + cmd = "gpg --homedir #{@tmpdir.shellescape} --no-default-keyring " \ + "--keyring #{@keyring.shellescape} --import #{@key_file.shellescape} 2>&1" out = `#{cmd}` if $CHILD_STATUS.exitstatus != 0 @@ -39,7 +41,8 @@ def run_import_key end def run_verify_signature - cmd = "gpg --homedir #{@tmpdir} --no-default-keyring --keyring #{@keyring} --verify #{@signature_file} #{@metadata_file} 2>&1" + cmd = "gpg --homedir #{@tmpdir.shellescape} --no-default-keyring " \ + "--keyring #{@keyring.shellescape} --verify #{@signature_file.shellescape} #{@metadata_file.shellescape} 2>&1" out = `#{cmd}` if $CHILD_STATUS.exitstatus != 0 diff --git a/lib/rmt/scc.rb b/lib/rmt/scc.rb index 1ba9158ff..59fccdf2a 100644 --- a/lib/rmt/scc.rb +++ b/lib/rmt/scc.rb @@ -84,7 +84,7 @@ def sync_systems scc_api_client = SUSE::Connect::Api.new(Settings.scc.username, Settings.scc.password) # do not sync BYOS proxy systems to SCC - systems = System.where('scc_registered_at IS NULL OR last_seen_at > scc_registered_at').where(proxy_byos: false) + systems = System.where('scc_registered_at IS NULL OR last_seen_at > scc_registered_at').not_byos @logger.info(_('Syncing %{count} updated system(s) to SCC') % { count: systems.size }) begin diff --git a/package/obs/rmt-server.changes b/package/obs/rmt-server.changes index dffd1fbc6..16e598617 100644 --- a/package/obs/rmt-server.changes +++ b/package/obs/rmt-server.changes @@ -1,7 +1,39 @@ ------------------------------------------------------------------- -Fri Jun 28 23:55:02 CEST 2024 - zpetrova@suse.de +Wed Oct 30 09:01:32 UTC 2024 - Natnael Getahun -- fixes for RES7-LTSS and OL7-LTSS clients +- Version 2.20 (unreleased) + * RMT packaging: don't overwrite custom sync/mirror timer config on package update + * Extend column size for repository and file paths (bsc#1229152) + * Forward suseconnect client user-agents to SCC + * rmt-server-pubcloud: + * Fix LTSS product verification (bsc#1230154) + * Fix activations check when no product info is available (bsc#1230157) + * Fix Azure SCC connection (bsc#1233314) + * Deny access of Azure Basic type images to LTSS + * Allow SLE Micro system to access SLES repositories (bsc#1230419) + +------------------------------------------------------------------- +Wed Aug 21 15:28:43 UTC 2024 - Jesús Bermúdez Velázquez + +- Version 2.19 + * Fix for mirroring products that contain special characters (eg.: '$') in their path + * Fix for packages with path longer 255 characters (bsc#1229152) + * rmt-server-pubcloud: + * Support registration of extensions in BYOS mode on top of a PAYG system (hybrid mode) (jsc#PCT-400) + * Validate repository and registy access for hybrid systems + +------------------------------------------------------------------- +Mon Jul 8 09:59:59 UTC 2024 - Thomas Schmidt + +- Include new script to fix yum-utils issue (jsc#SLL-369) + +------------------------------------------------------------------- +Mon July 1 12:42:34 UTC 2024 - Adnilson Delgado + +- Version 2.18 + * Move temporary storage of downloaded files to the repo directory to avoid filling up /tmp partition. (gh:#1137) + * Fixes for RES7-LTSS and OL7-LTSS clients + * Instance Verification: re-setting the repository and registry cache path to the right value; update the cache scrubber paths ------------------------------------------------------------------- Thu April 18 09:27:00 UTC 2024 - Adnilson Delgado diff --git a/package/obs/rmt-server.spec b/package/obs/rmt-server.spec index 67387a9f6..5e357fbbf 100644 --- a/package/obs/rmt-server.spec +++ b/package/obs/rmt-server.spec @@ -34,7 +34,7 @@ %undefine _find_debuginfo_dwz_opts Name: rmt-server -Version: 2.17 +Version: 2.19 Release: 0 Summary: Repository mirroring tool and registration proxy for SCC License: GPL-2.0-or-later @@ -269,6 +269,7 @@ chrpath -d %{buildroot}%{lib_dir}/vendor/bundle/ruby/*/extensions/*/*/mysql2-*/m %dir %{_sysconfdir}/slp.reg.d %config(noreplace) %attr(0640, %{rmt_user}, root) %{_sysconfdir}/rmt.conf %config(noreplace) %{_sysconfdir}/slp.reg.d/rmt-server.reg + %{_mandir}/man8/rmt-cli.8%{?ext_man} %{_bindir}/rmt-cli %{_bindir}/rmt-data-import @@ -289,6 +290,11 @@ chrpath -d %{buildroot}%{lib_dir}/vendor/bundle/ruby/*/extensions/*/*/mysql2-*/m %{_unitdir}/rmt-server-systems-scc-sync.timer %{_unitdir}/rmt-uptime-cleanup.service %{_unitdir}/rmt-uptime-cleanup.timer +%config(noreplace) %{_unitdir}/rmt-server-mirror.timer +%config(noreplace) %{_unitdir}/rmt-server-sync.timer +%config(noreplace) %{_unitdir}/rmt-server-systems-scc-sync.timer +%config(noreplace) %{_unitdir}/rmt-uptime-cleanup.timer + %dir %{_datadir}/bash-completion/ %dir %{_datadir}/bash-completion/completions/ %{_datadir}/bash-completion/completions/rmt-cli @@ -323,6 +329,8 @@ chrpath -d %{buildroot}%{lib_dir}/vendor/bundle/ruby/*/extensions/*/*/mysql2-*/m %{_unitdir}/rmt-server-regsharing.timer %{_unitdir}/rmt-server-trim-cache.service %{_unitdir}/rmt-server-trim-cache.timer +%config(noreplace) %{_unitdir}/rmt-server-regsharing.timer +%config(noreplace) %{_unitdir}/rmt-server-trim-cache.timer %pre getent group %{rmt_group} >/dev/null || %{_sbindir}/groupadd -r %{rmt_group} diff --git a/package/obs/rmt.conf b/package/obs/rmt.conf index c2f410e24..a1d6eca60 100644 --- a/package/obs/rmt.conf +++ b/package/obs/rmt.conf @@ -12,7 +12,10 @@ scc: username: password: sync_systems: true - + metrics: + enabled: false + job_name: rmt + mirroring: mirror_src: false dedup_method: hardlink diff --git a/public/tools/rmt-client-setup-res b/public/tools/rmt-client-setup-res index 78f20dc32..e467d7d02 100755 --- a/public/tools/rmt-client-setup-res +++ b/public/tools/rmt-client-setup-res @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # # rmt-client-setup-res: client use rmt-client-setup script to register with rmt. # This script assumes SUSEConnect is already installed on the system. @@ -119,7 +119,7 @@ else CERTURL="$REGCERT" fi -$CURL --tlsv1.2 --silent --insecure --connect-timeout 10 --output $TEMPFILE $CERTURL +$CURL --tlsv1.2 --silent --insecure --connect-timeout 10 --output "$TEMPFILE" "$CERTURL" if [ $? -ne 0 ]; then echo "Download failed. Abort." exit 1 @@ -163,14 +163,12 @@ echo "Detected ${SLL_name} version: ${SLL_version}" echo "Importing repomd.xml.key" if [[ ${SLL_version} -eq 7 ]]; then - $CURL --silent --show-error --insecure ${REGURL}/repo/SUSE/Updates/${SLL_name%%-LTSS}/${SLL_version}-LTSS/x86_64/update/repodata/repomd.xml.key --output repomd.xml.key + $CURL --silent --show-error --insecure "${REGURL}/repo/SUSE/Updates/${SLL_name%%-LTSS}/${SLL_version}-LTSS/x86_64/update/repodata/repomd.xml.key" --output repomd.xml.key else - $CURL --silent --show-error --insecure ${REGURL}/repo/SUSE/Updates/${SLL_name}/${SLL_version}/x86_64/update/repodata/repomd.xml.key --output repomd.xml.key + $CURL --silent --show-error --insecure "${REGURL}/repo/SUSE/Updates/${SLL_name}/${SLL_version}/x86_64/update/repodata/repomd.xml.key" --output repomd.xml.key fi $RPM --import repomd.xml.key -if [ ! -x $SUSECONNECT ]; then - echo "Downloading SUSEConnect" if [[ ${SLL_version} -gt 7 ]]; then if [ ! -x $DNF ]; then @@ -180,14 +178,13 @@ if [[ ${SLL_version} -gt 7 ]]; then echo "Disabling all repositories" $DNF config-manager --disable $(dnf repolist -q | awk '{ print $1 }' | grep -v repo) - # sed -i 's/^enabled=1/enabled=0/' /etc/yum.repos.d/* # on RHEL9 (not RHEL8) redhat-release is protected and cannot be updated to sll-release if [ -f /etc/dnf/protected.d/redhat-release.conf ]; then rm -f /etc/dnf/protected.d/redhat-release.conf fi - $DNF config-manager --add-repo ${REGURL}/repo/SUSE/Updates/${SLL_name}/${SLL_version}/x86_64/update - $DNF config-manager --add-repo ${REGURL}/repo/SUSE/Updates/${SLL_name}-AS/${SLL_version}/x86_64/update + $DNF config-manager --add-repo "${REGURL}/repo/SUSE/Updates/${SLL_name}/${SLL_version}/x86_64/update" + $DNF config-manager --add-repo "${REGURL}/repo/SUSE/Updates/${SLL_name}-AS/${SLL_version}/x86_64/update" $DNF install -y --allowerasing ${SLL_release_package} # For RHEL8/CentOS8, remove all old signing keys and import SUSE keys installed with sles_es-release package @@ -195,17 +192,29 @@ if [[ ${SLL_version} -gt 7 ]]; then import_rpm_signing_keys fi + echo "Downloading SUSEConnect" $DNF install SUSEConnect librepo + $DNF config-manager --set-disabled "${RMTNAME}_repo_SUSE_Updates_${SLL_name}_${SLL_version}_x86_64_update" $DNF config-manager --set-disabled "${RMTNAME}_repo_SUSE_Updates_${SLL_name}-AS_${SLL_version}_x86_64_update" elif [[ ${SLL_version} -eq 7 ]]; then - # For SLL7 we need to have yum, yum_config_mgr, sles_os-release-server, etc.. - if [ ! -x "$YUM_CONFIG_MGR" ]; then - echo "YUM config manager is not installed. Please install yum-config-manager and retry. Abort." - exit 1 - fi - + + # disbale unavailable centos mirrors + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo + + # Check for and install yum-config-manager if not available + if [ ! -x $YUM_CONFIG_MGR ]; then + echo "yum-config-manager not found. Attempting to install." + $YUM install -y yum-utils + if [ $? -ne 0 ]; then + echo "Failed to install yum-config-manager. Abort." + exit 1 + fi + fi + echo "Disabling all repositories" $YUM_CONFIG_MGR --disable \* > /dev/null @@ -215,25 +224,31 @@ elif [[ ${SLL_version} -eq 7 ]]; then rm -f /usr/share/redhat-release fi - $YUM_CONFIG_MGR --add-repo ${REGURL}/repo/SUSE/Updates/${SLL_name%%-LTSS}/${SLL_version}-LTSS/x86_64/update + $YUM_CONFIG_MGR --add-repo "${REGURL}/repo/SUSE/Updates/${SLL_name%%-LTSS}/${SLL_version}-LTSS/x86_64/update" if [ ${SLL_name} = "RES-OL-LTSS" ]; then - $YUM_CONFIG_MGR --add-repo ${REGURL}/repo/SUSE/Updates/RES-BASE/${SLL_version}/x86_64/update + $YUM_CONFIG_MGR --add-repo "${REGURL}/repo/SUSE/Updates/RES-BASE/${SLL_version}/x86_64/update" fi $YUM_CONFIG_MGR --enable *suse.* > /dev/null - $YUM install -y ${SLL_release_package} suseconnect-ng librepo + if [ ! -x $SUSECONNECT ]; then + $YUM install -y --nogpgcheck ${SLL_release_package} + import_rpm_signing_keys + $YUM install -y suseconnect-ng librepo + else + $YUM update -y ${SLL_release_package} suseconnect-ng librepo + fi + $YUM update -y yum $YUM_CONFIG_MGR --disable \* > /dev/null -fi elif [[ ${SLL_version} -eq 8 ]]; then # For SLL8, the release package is already installed, just import the keys import_rpm_signing_keys fi -$CURL --silent --show-error --insecure $REGURL/tools/rmt-client-setup --output rmt-client-setup +$CURL --silent --show-error --insecure "$REGURL/tools/rmt-client-setup" --output rmt-client-setup echo "Running rmt-client-setup $PARAMS" if [ -n "$YES_PARAM" ]; then - PARAMS=$(echo $PARAMS | sed 's/--yes//') + PARAMS=$(echo "$PARAMS" | sed 's/--yes//') yes | sh rmt-client-setup $PARAMS else sh rmt-client-setup $PARAMS diff --git a/spec/factories/products.rb b/spec/factories/products.rb index a660506bd..4612dfb00 100644 --- a/spec/factories/products.rb +++ b/spec/factories/products.rb @@ -9,7 +9,7 @@ product_type { :base } sequence(:description) { FFaker::Lorem.sentence } release_type { nil } - version { 42 } + version { 15.3 } arch { 'x86_64' } release_stage { 'released' } @@ -57,6 +57,23 @@ friendly_version { '15 SP3' } end + trait :product_sles_ltss do + identifier { 'SLES-LTSS' } + name { 'SUSE Linux Enterprise Server LTSS' } + description { 'SUSE Linux Enterprise offers a comprehensive suite of products...' } + shortname { 'SLES15-SP3-LTSS' } + former_identifier { 'SLES_LTSS' } + product_type { 'extension' } + product_class { 'LTSS' } + release_type { nil } + release_stage { 'released' } + version { '15.3' } + arch { 'x86_64' } + free { false } + cpe { 'cpe:/o:suse:sles:15:sp3' } + friendly_version { '15 SP3' } + end + trait :product_sles_sap do identifier { 'SLES_SAP' } name { 'SUSE Linux Enterprise Server' } @@ -73,6 +90,22 @@ friendly_version { '15 SP3' } end + trait :product_sle_micro do + identifier { 'SLE-Micro' } + name { 'SUSE Linux Enterprise Server' } + description { 'SUSE Linux Enterprise offers a comprehensive suite of products...' } + shortname { 'SLES15-SP6' } + former_identifier { 'SUSE_SLES_MICRO' } + product_type { :base } + release_type { nil } + release_stage { 'released' } + version { '15.6' } + arch { 'x86_64' } + free { false } + cpe { 'cpe:/o:suse:sles_sap:15:sp6' } + friendly_version { '15 SP6' } + end + trait :extension do product_type { 'extension' } end diff --git a/spec/factories/systems.rb b/spec/factories/systems.rb index d826bb0f3..ceab7d02e 100644 --- a/spec/factories/systems.rb +++ b/spec/factories/systems.rb @@ -10,6 +10,14 @@ virtual { false } end + trait :payg do + proxy_byos_mode { :payg } + end + + trait :hybrid do + proxy_byos_mode { :hybrid } + end + trait :synced do sequence(:scc_system_id) { |n| n } @@ -19,7 +27,7 @@ end trait :byos do - proxy_byos { true } + proxy_byos_mode { :byos } end trait :with_activated_base_product do @@ -49,6 +57,17 @@ end end + trait :with_activated_product_sle_micro do + transient do + product { create(:product, :product_sle_micro, :with_mirrored_repositories) } + subscription { nil } + end + + after :create do |system, evaluator| + create(:activation, system: system, service: evaluator.product.service, subscription: evaluator.subscription) + end + end + trait :with_system_information do system_information do { diff --git a/spec/lib/rmt/gpg_spec.rb b/spec/lib/rmt/gpg_spec.rb index 00b687432..fcc90ca2e 100644 --- a/spec/lib/rmt/gpg_spec.rb +++ b/spec/lib/rmt/gpg_spec.rb @@ -23,6 +23,14 @@ end end + context 'when the path contains characters that need to get escaped' do + it 'returns true' do + allow(Dir).to receive(:mktmpdir).with('rmt-mirror-gpg') + .and_return(Dir.mktmpdir('rmt-mirr$r-gpg')) + expect(verifier.verify_signature).to eq(true) + end + end + context 'when the GPG key is invalid' do let(:key_file) { File.join(file_fixture('gpg'), 'bad.xml.key') } diff --git a/spec/lib/rmt/scc_spec.rb b/spec/lib/rmt/scc_spec.rb index 94b2a739d..d1e0aa73e 100644 --- a/spec/lib/rmt/scc_spec.rb +++ b/spec/lib/rmt/scc_spec.rb @@ -469,6 +469,7 @@ def scc end let(:system) { FactoryBot.create(:system) } + let(:system_hybrid) { FactoryBot.create(:system, :hybrid) } it 'syncs systems' do expect(api_double).to receive(:send_bulk_system_update).with([system]) @@ -481,6 +482,18 @@ def scc }) described_class.new.sync_systems end + + it 'syncs systems hybrid' do + expect(api_double).to receive(:send_bulk_system_update).with([system_hybrid]) + .and_return({ + systems: [{ + id: 10, + login: system_hybrid.login, + password: system_hybrid.password + }] + }) + described_class.new.sync_systems + end end end diff --git a/spec/requests/api/connect/v3/systems/products_controller_spec.rb b/spec/requests/api/connect/v3/systems/products_controller_spec.rb index 60c1bbb0d..457a77d65 100644 --- a/spec/requests/api/connect/v3/systems/products_controller_spec.rb +++ b/spec/requests/api/connect/v3/systems/products_controller_spec.rb @@ -246,6 +246,33 @@ end end + context 'when SLE Micro product is activated' do + let(:system) { FactoryBot.create(:system, :with_activated_product_sle_micro) } + let(:product) { FactoryBot.create(:product, :product_sles, :with_mirrored_repositories) } + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: system.products.first.arch + } + end + let(:serialized_json) do + V3::ProductSerializer.new( + product, + base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s + ).to_json + end + + describe 'response' do + subject { response } + + before { get url, headers: headers, params: payload } + + its(:code) { is_expected.to eq('200') } + its(:body) { is_expected.to eq(serialized_json) } + end + end + context 'with eula_url' do subject { response } diff --git a/spec/requests/api/connect/v3/systems/systems_controller_spec.rb b/spec/requests/api/connect/v3/systems/systems_controller_spec.rb index 14cb0f07e..95eede620 100644 --- a/spec/requests/api/connect/v3/systems/systems_controller_spec.rb +++ b/spec/requests/api/connect/v3/systems/systems_controller_spec.rb @@ -3,6 +3,8 @@ RSpec.describe Api::Connect::V3::Systems::SystemsController do include_context 'auth header', :system, :login, :password include_context 'version header', 3 + include_context 'user-agent header' + include_context 'zypp user-agent header' let(:system) { FactoryBot.create(:system, hostname: 'initial') } let(:url) { '/connect/systems' } @@ -105,6 +107,24 @@ expect(system.reload.hostname).to be_nil end end + + context 'stores client\'s user-agent' do + let(:headers) { auth_header.merge(user_agent_header) } + + it 'stores suseconnect version' do + update_action + expect(system.reload.system_information_hash[:user_agent]).to eq('suseconnect-ng/1.2') + end + end + + context 'doesn\'t store zypp user-agent' do + let(:headers) { auth_header.merge(zypp_user_agent_header) } + + it 'ignores zypp user-agent' do + update_action + expect(system.reload.system_information_hash[:user_agent]).to be_nil + end + end end describe '#deregister' do diff --git a/spec/support/shared_contexts/headers.rb b/spec/support/shared_contexts/headers.rb index 352363776..f1946a453 100644 --- a/spec/support/shared_contexts/headers.rb +++ b/spec/support/shared_contexts/headers.rb @@ -2,6 +2,14 @@ let(:version_header) { { 'Accept' => "application/vnd.scc.suse.com.v#{version}+json" } } end +shared_context 'user-agent header' do |_version| + let(:user_agent_header) { { 'User-Agent' => 'suseconnect-ng/1.2' } } +end + +shared_context 'zypp user-agent header' do |_version| + let(:zypp_user_agent_header) { { 'User-Agent' => 'zypp/1.0' } } +end + shared_context 'auth header' do |login_object, login_method, password_method| let(:auth_header) do basic_auth_header send(login_object).send(login_method), send(login_object).send(password_method)