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/workflows/lint-unit.yml b/.github/workflows/lint-unit.yml index fc5842d18..e97009ec4 100644 --- a/.github/workflows/lint-unit.yml +++ b/.github/workflows/lint-unit.yml @@ -81,8 +81,13 @@ jobs: - 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 rake test:core + - name: Run PubCloud engines tests run: | bundle exec rake test:engines @@ -91,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 552fb8205..4f97e5013 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 9ccead6eb..93d77c0ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,8 +30,10 @@ 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) @@ -136,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.2) + jwt (2.9.3) base64 listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -160,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) @@ -173,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) @@ -209,8 +213,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.6) - strscan + rexml (3.3.9) ronn (0.7.3) hpricot (>= 0.8.2) mustache (>= 0.7.0) @@ -267,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-xz (1.0.3) ruby_parser (3.19.2) @@ -297,7 +301,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) @@ -318,10 +321,27 @@ GEM activesupport (>= 3) railties (>= 3) yard (~> 0.9.20) - webmock (3.23.1) + 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) @@ -352,7 +372,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) @@ -384,6 +404,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 c58ab2695..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: @@ -109,4 +109,4 @@ RMT helm chart is defined [here](https://github.com/SUSE/helm-charts.git) and pu 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. -Please reach out to the BCI team if you have build related questions. +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/base_controller.rb b/app/controllers/api/connect/base_controller.rb index 1fbeaff07..84de46574 100644 --- a/app/controllers/api/connect/base_controller.rb +++ b/app/controllers/api/connect/base_controller.rb @@ -44,4 +44,14 @@ def authenticate_with_token end end + def system_token_header + headers[SYSTEM_TOKEN_HEADER] = @system.system_token + end + + def refresh_system_token + if system_tokens_enabled? + @system.update(system_token: SecureRandom.uuid) + system_token_header + end + end end diff --git a/app/controllers/api/connect/v3/systems/products_controller.rb b/app/controllers/api/connect/v3/systems/products_controller.rb index 2545a9e92..648443ecd 100644 --- a/app/controllers/api/connect/v3/systems/products_controller.rb +++ b/app/controllers/api/connect/v3/systems/products_controller.rb @@ -5,6 +5,7 @@ class Api::Connect::V3::Systems::ProductsController < Api::Connect::BaseControll before_action :check_product_service_and_repositories, only: %i[show activate] before_action :load_subscription, only: %i[activate upgrade] before_action :check_base_product_dependencies, only: %i[activate upgrade show] + after_action :refresh_system_token, only: %i[activate upgrade], if: -> { request.headers.key?(SYSTEM_TOKEN_HEADER) } def activate create_product_activation @@ -12,7 +13,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, diff --git a/app/controllers/api/connect/v3/systems/systems_controller.rb b/app/controllers/api/connect/v3/systems/systems_controller.rb index b7622e252..d9a17154d 100644 --- a/app/controllers/api/connect/v3/systems/systems_controller.rb +++ b/app/controllers/api/connect/v3/systems/systems_controller.rb @@ -1,6 +1,7 @@ class Api::Connect::V3::Systems::SystemsController < Api::Connect::BaseController before_action :authenticate_system + after_action :refresh_system_token, only: [:update], if: -> { request.headers.key?(SYSTEM_TOKEN_HEADER) } def update if params[:online_at].present? @@ -21,8 +22,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/api/connect/v4/systems/products_controller.rb b/app/controllers/api/connect/v4/systems/products_controller.rb index 7237e5a80..d4c87adfc 100644 --- a/app/controllers/api/connect/v4/systems/products_controller.rb +++ b/app/controllers/api/connect/v4/systems/products_controller.rb @@ -1,5 +1,6 @@ class Api::Connect::V4::Systems::ProductsController < Api::Connect::V3::Systems::ProductsController + after_action :refresh_system_token, only: %i[activate upgrade synchronize destroy], if: -> { request.headers.key?(SYSTEM_TOKEN_HEADER) } def destroy if @product.base? raise ActionController::TranslatedError.new(N_('The product "%s" is a base product and cannot be deactivated'), @product.name) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 12eaadef0..63458615a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,12 +18,13 @@ 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. + # that supports this feature. if system_tokens_enabled? && request.headers.key?(SYSTEM_TOKEN_HEADER) - @system.update(last_seen_at: Time.zone.now, system_token: SecureRandom.uuid) - headers[SYSTEM_TOKEN_HEADER] = @system.system_token + @system.update(last_seen_at: Time.zone.now) + system_token_header # only update last_seen_at each 3 minutes, # so that a system that calls SCC every second doesn't write + lock the database row elsif !@system.last_seen_at || @system.last_seen_at < 3.minutes.ago @@ -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/repository.rb b/app/models/repository.rb index 6a7719aa2..c65c602cd 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -23,14 +23,15 @@ class Repository < ApplicationRecord class << self def remove_suse_repos_without_tokens! - where(auth_token: nil).where('external_url LIKE ?', 'https://updates.suse.com%').delete_all + where(auth_token: nil).where("external_url LIKE '%.suse.com%'").where(installer_updates: 0).where.not(scc_id: nil).delete_all end # Mangles remote repo URL to make a nicer local path, see specs for examples def make_local_path(url) uri = URI(url) path = uri.path.to_s - path.gsub!(%r{^/repo}, '') if (uri.hostname == 'updates.suse.com') + # drop '/repo' from SLE11 paths, to avoid double /repo/repo in local storage path. + path.gsub!(%r{^/repo/\$RCE/}, '/$RCE/') (path == '') ? '/' : path end diff --git a/app/models/system.rb b/app/models/system.rb index be98e602b..2d9f97808 100644 --- a/app/models/system.rb +++ b/app/models/system.rb @@ -37,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/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..6a2df5b5c 100644 --- a/config/rmt.yml +++ b/config/rmt.yml @@ -20,10 +20,13 @@ database_test: database: rmt_test scc: - host: https://scc.suse.com/connect + host: <%= ENV.fetch('SCC_HOST'){ 'https://scc.suse.com/connect' } %> 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/20240821114908_change_local_path_type.rb b/db/migrate/20240821114908_change_local_path_type.rb index 83e0789e9..5c2f53cce 100644 --- a/db/migrate/20240821114908_change_local_path_type.rb +++ b/db/migrate/20240821114908_change_local_path_type.rb @@ -1,8 +1,8 @@ class ChangeLocalPathType < ActiveRecord::Migration[6.1] def up safety_assured do - change_column :repositories, :local_path, :text - change_column :downloaded_files, :local_path, :text + change_column :repositories, :local_path, :string, limit: 512 + change_column :downloaded_files, :local_path, :string, limit: 512 end end diff --git a/db/schema.rb b/db/schema.rb index bf4ce342e..1584f1814 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -34,7 +34,7 @@ create_table "downloaded_files", charset: "utf8", force: :cascade do |t| t.string "checksum_type" t.string "checksum" - t.text "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.text "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 diff --git a/engines/instance_verification/app/controllers/instance_verification/billing_check_controller.rb b/engines/instance_verification/app/controllers/instance_verification/billing_check_controller.rb index 9d00eee26..0217e54d7 100644 --- a/engines/instance_verification/app/controllers/instance_verification/billing_check_controller.rb +++ b/engines/instance_verification/app/controllers/instance_verification/billing_check_controller.rb @@ -5,7 +5,7 @@ def check # belongs to a PAYG or BYOS instance verification_provider = InstanceVerification.provider.new( logger, - nil, + request, nil, params[:metadata] ) 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 261775111..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 @@ -38,7 +38,7 @@ 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 + .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) @@ -71,10 +71,18 @@ end context 'when verification provider returns false' do + let(:plugin_double) { instance_double('InstanceVerification::Providers::Example') } + 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_return(false) + 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 @@ -86,9 +94,13 @@ 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) - expect(plugin_double).to receive(:instance_valid?).and_raise('Custom plugin error') + 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 @@ -106,7 +118,7 @@ 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 + .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) @@ -134,8 +146,9 @@ 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) + .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 @@ -148,8 +161,9 @@ 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) + .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 @@ -164,9 +178,9 @@ 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) + .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 @@ -220,9 +234,9 @@ end before do - allow(InstanceVerification::Providers::Example).to receive(:new) - .with(nil, nil, nil, instance_data).and_return(plugin_double) + 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) @@ -315,7 +329,7 @@ let(:scc_annouce_body) do { hostname: system.hostname, - hwinfo: JSON.parse(system.system_information).merge({ instance_data: system.instance_data }), + hwinfo: JSON.parse(system.system_information), byos_mode: 'hybrid', login: system.login, password: system.password @@ -332,9 +346,10 @@ end before do - allow(InstanceVerification::Providers::Example).to receive(:new) - .with(nil, nil, nil, instance_data).and_return(plugin_double) + 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) @@ -373,8 +388,9 @@ before do allow(InstanceVerification::Providers::Example).to receive(:new) - .with(nil, nil, nil, instance_data).and_return(plugin_double) + .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) @@ -390,7 +406,7 @@ .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 @@ -402,12 +418,16 @@ 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 system doesn't have hw_info" do let(:system) { FactoryBot.create(:system, :hybrid) } 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 + .and_call_original.at_least(:once) allow(File).to receive(:directory?) allow(Dir).to receive(:mkdir) allow(FileUtils).to receive(:touch) @@ -435,7 +455,8 @@ 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) + .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 @@ -449,7 +470,8 @@ 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) + .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 @@ -507,8 +529,9 @@ before do allow(InstanceVerification::Providers::Example).to receive(:new) - .with(nil, nil, nil, instance_data).and_return(plugin_double) + .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) @@ -540,6 +563,7 @@ 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 @@ -551,65 +575,176 @@ 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) } + + 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 - 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 201' do + expect(response).to have_http_status(201) + end - it 'HTTP response code is 422' do - expect(response).to have_http_status(422) + 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 diff --git a/engines/registry/app/models/access_scope.rb b/engines/registry/app/models/access_scope.rb index fa565cdbb..b15c0b17b 100644 --- a/engines/registry/app/models/access_scope.rb +++ b/engines/registry/app/models/access_scope.rb @@ -87,15 +87,15 @@ def allowed_paths(system = nil) 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).free? } + 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 = { - Authorization: ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) + '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.login, system.system_token, Rails.logger, system.proxy_byos_mode, non_free_prod_class - ) + 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]}" diff --git a/engines/registry/spec/app/models/access_scope_spec.rb b/engines/registry/spec/app/models/access_scope_spec.rb index 0840efb53..0d3c84a22 100644 --- a/engines/registry/spec/app/models/access_scope_spec.rb +++ b/engines/registry/spec/app/models/access_scope_spec.rb @@ -194,7 +194,7 @@ system end let(:product1) do - product = FactoryBot.create(:product, :with_mirrored_repositories) + 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 @@ -208,29 +208,22 @@ } end let(:header_expected) do - { Authorization: ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } + { '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.login, - system.system_token, - Rails.logger, - system.proxy_byos_mode, + system, 'SLES15-SP4-LTSS-X86' ).and_return(scc_response) end - # rubocop:disable RSpec/ExampleLength it 'returns no actions allowed' do expect(SccProxy).to receive(:scc_check_subscription_expiration).with( header_expected, - system.login, - system.system_token, - Rails.logger, - system.proxy_byos_mode, + system, 'SLES15-SP4-LTSS-X86' ) yaml_string = access_policy_content @@ -250,7 +243,6 @@ } ) end - # rubocop:enable RSpec/ExampleLength end end 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 a16896fbb..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,7 +3,7 @@ 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 @@ -57,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 08e5cae1f..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)) @@ -117,6 +104,17 @@ def prepare_scc_request(uri_path, product, auth, params, mode) 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 + def announce_system_scc(auth, params) uri = URI.parse(ANNOUNCE_URL) http = Net::HTTP.new(uri.host, uri.port) @@ -128,15 +126,26 @@ def announce_system_scc(auth, params) JSON.parse(response.body) end - def scc_activate_product(product, auth, params, mode) - 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, params, mode) - http.request(scc_request) + 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 @@ -147,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) @@ -165,14 +188,19 @@ def parse_error(error_message, token = nil, email = nil) error_message end - def get_scc_activations(headers, system_token, mode) + 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_mode: mode }) - scc_request = Net::HTTP::Get.new(uri.path, headers(auth, system_token)) - http.request(scc_request) + 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 + Rails.logger.info "Could not get the system (#{system.login}) activations, error: #{response.message} #{response.code}" + raise ActionController::TranslatedError.new(response.body) + end + JSON.parse(response.body) end def product_path_access(x_original_uri, products_ids) @@ -200,11 +228,17 @@ def product_class_access(scc_systems_activations, product) 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 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 @@ -229,15 +263,10 @@ def activations_fail_state(scc_systems_activations, headers, product = nil) end end end + # rubocop:enable Metrics/PerceivedComplexity - def scc_check_subscription_expiration(headers, login, system_token, logger, mode, product = nil) # rubocop:disable Metrics/ParameterLists - response = SccProxy.get_scc_activations(headers, system_token, mode) - 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 } - end - scc_systems_activations = JSON.parse(response.body) + 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| @@ -246,6 +275,22 @@ def scc_check_subscription_expiration(headers, login, system_token, logger, mode 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 @@ -261,11 +306,16 @@ 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, proxy_byos_mode: :payg) + @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) @@ -275,7 +325,9 @@ def announce_system password: response['password'], hostname: params[:hostname], proxy_byos_mode: :byos, - system_information: system_information + proxy_byos: true, + system_information: system_information, + instance_data: instance_data ) end logger.info("System '#{@system.hostname}' announced") @@ -303,55 +355,26 @@ 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 - # rubocop:disable Metrics/PerceivedComplexity - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/AbcSize def scc_activate_product - logger.info "Activating product #{@product.product_string} to SCC" - auth = nil - auth = request.headers['HTTP_AUTHORIZATION'] if request.headers.include?('HTTP_AUTHORIZATION') - mode = nil - if @system.byos? - mode = 'byos' - elsif !@product.free? && @product.extension? && params[:token].present? - mode = 'hybrid' - # the extensions must be the same version and arch - # than base product - base_prod = @system.products.find_by(product_type: :base) - if @system.payg? && base_prod.present? - raise 'Incompatible extension product' unless @product.arch == base_prod.arch && @product.version == base_prod.version - - 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['hwinfo']['instance_data'] = @system.instance_data - announce_auth = "Token token=#{params[:token]}" - - response = SccProxy.announce_system_scc(announce_auth, params) - 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 - response = SccProxy.scc_activate_product(@product, auth, params, mode) - 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' - if @system.payg? - # 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 - deregister_hybrid(request.headers['HTTP_AUTHORIZATION']) - end - raise ActionController::TranslatedError.new(error['error']) - end + 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 @@ -360,23 +383,51 @@ def scc_activate_product logger.info "Product #{@product.product_string} successfully activated with SCC" InstanceVerification.update_cache(request.remote_ip, @system.login, @product.id) end - logger.info 'No token provided' if params[:token].blank? end - def deregister_hybrid(auth) - 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']) + 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 - logger.info 'System successfully deregistered from SCC' + 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 - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:enable Metrics/PerceivedComplexity Api::Connect::V4::Systems::ProductsController.class_eval do before_action :scc_deactivate_product, only: %i[destroy] @@ -386,42 +437,28 @@ def deregister_hybrid(auth) def scc_deactivate_product auth = request.headers['HTTP_AUTHORIZATION'] if @system.byos? && @product[:product_type] != 'base' - response = SccProxy.deactivate_product_scc(auth, @product, @system.system_token) - handle_response(response) + 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_systems_activations = find_hybrid_product_on_scc(request.headers) - result = SccProxy.product_class_access(scc_systems_activations, @product.product_class) - if result[:is_active] || (!result[:is_active] && result[:message].downcase.include?('expired')) - # if product is active on SCC or - # product subscription expired + 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 - response = SccProxy.deactivate_product_scc(auth, @product, @system.system_token) - handle_response(response) - elsif result[:message].downcase.include?('unexpected error') - raise ActionController::TranslatedError.new(result[:message]) + 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 end logger.info "Product '#{@product.friendly_name}' successfully deactivated from SCC" end - def find_hybrid_product_on_scc(headers) - response = SccProxy.get_scc_activations(headers, @system.system_token, @system.proxy_byos_mode) - unless response.code_type == Net::HTTPOK - logger.info "Could not get the system (#{@system.login}) activations, error: #{response.message} #{response.code}" - raise ActionController::TranslatedError.new(response.body) - end - JSON.parse(response.body) - end - - def handle_response(response) - 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 + 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 @@ -433,15 +470,7 @@ def handle_response(response) def scc_deregistration if @system.byos? || @system.hybrid? # byos and hybrid systems should get de-register from SCC - 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' + SccProxy.deregister_system_scc(request.headers['HTTP_AUTHORIZATION'], @system) end end end 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 9daced829..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 @@ -354,7 +354,7 @@ end let(:product) do FactoryBot.create( - :product, :product_sles, :extension, :with_mirrored_repositories, :with_mirrored_extensions, + :product, :product_sles_ltss, :extension, :with_mirrored_repositories, :with_mirrored_extensions, base_products: [system_payg.products.first] ) end @@ -415,7 +415,120 @@ 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 + + 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 + + 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 + + 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).to receive(:write_cache_file).twice.with( Rails.application.config.repo_cache_dir, "127.0.0.1-#{system_payg.login}-#{product.id}" ) @@ -474,7 +587,10 @@ 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, @@ -483,11 +599,11 @@ ) 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 + 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 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 4a38985e5..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 @@ -187,7 +187,7 @@ end end - context 'when SCC API returns an error' do + 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 @@ -204,200 +204,123 @@ base_url: URI::HTTP.build({ scheme: response.request.scheme, host: response.request.host }).to_s ).to_json end - 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 - } - } - } - 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: 422, + 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].to_json, 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 - - context 'when product is expired' 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(: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 + 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 + } } - } - } - end - let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } - - before do - stub_request(:delete, scc_systems_products_url) - .to_return( - status: 422, - body: "{\"error\": \"Could not de-activate product \'#{product.friendly_name}\'\"}", - headers: {} - ) - stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_expired].to_json, 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 - - context 'when product is not active' 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(:body_not_activated) 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: { + }].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, - product_class: product.product_class + 'FOO' - } - } - } - 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: 200, body: [body_not_activated].to_json, headers: {}) - delete url, params: payload, headers: headers - end - - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq(nil) - end - end - - context 'when product has unknown status' 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(:body_unknown_status) 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 + 'FOO' + 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' + } + } } - } - } - end - let(:scc_systems_activations_url) { 'https://scc.suse.com/connect/systems/activations' } - - before do - stub_request(:delete, scc_systems_products_url) - .to_return( - status: 422, - body: "{\"error\": \"Could not de-activate product \'#{product.friendly_name}\'\"}", - headers: {} - ) - stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_unknown_status].to_json, headers: {}) - delete url, params: payload, headers: headers - end - - it 'reports an error' do - data = JSON.parse(response.body) - expect(data['error']).to eq('Unexpected error when checking product subscription.') + ].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 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 db43ac907..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,12 +31,13 @@ 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, logger) + 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 - return handle_scc_subscription(request, system, verification_provider, logger) if system.byos? + return handle_scc_subscription(request, system, verification_provider) if system.byos? ZypperAuth.zypper_auth_message(request, system, verification_provider, e.message) false @@ -49,8 +50,9 @@ def verify_instance(request, logger, system) false end - def handle_scc_subscription(request, system, verification_provider, logger) - result = SccProxy.scc_check_subscription_expiration(request.headers, system.login, system.system_token, logger, system.proxy_byos_mode) + 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]) @@ -128,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/strict_authentication/authentication_controller_spec.rb b/engines/zypper_auth/spec/requests/strict_authentication/authentication_controller_spec.rb index 643fe3f6f..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 @@ -245,21 +245,16 @@ before do stub_request(:get, scc_systems_activations_url).to_return(status: 200, body: [body_active].to_json, headers: {}) - # allow(SccProxy).to receive(:get_scc_activations).and_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) - # get '/api/auth/check', headers: headers end it 'returns true' do result = SccProxy.scc_check_subscription_expiration( headers, - system_hybrid.login, - system_hybrid.system_token, - nil, - system_hybrid.proxy_byos_mode, + system_hybrid, system_hybrid.activations.first.product.product_class + '-LTSS' ) expect(result[:is_active]).to be(true) @@ -298,10 +293,7 @@ it 'returns false, expired' do result = SccProxy.scc_check_subscription_expiration( headers, - system_hybrid.login, - system_hybrid.system_token, - nil, - system_hybrid.proxy_byos_mode, + system_hybrid, system_hybrid.activations.first.product.product_class + '-LTSS' ) expect(result[:is_active]).to eq(false) @@ -341,10 +333,7 @@ it 'returns product not activated' do result = SccProxy.scc_check_subscription_expiration( headers, - system_hybrid.login, - system_hybrid.system_token, - nil, - system_hybrid.proxy_byos_mode, + system_hybrid, system_hybrid.activations.first.product.product_class + '-LTSS' ) expect(result[:is_active]).to eq(false) @@ -384,10 +373,7 @@ it 'returns unexpected error' do result = SccProxy.scc_check_subscription_expiration( headers, - system_hybrid.login, - system_hybrid.system_token, - nil, - system_hybrid.proxy_byos_mode, + system_hybrid, system_hybrid.activations.first.product.product_class + '-LTSS' ) expect(result[:is_active]).to eq(false) 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/package/obs/rmt-server.changes b/package/obs/rmt-server.changes index 71ad51f29..c84e3dbf6 100644 --- a/package/obs/rmt-server.changes +++ b/package/obs/rmt-server.changes @@ -1,3 +1,19 @@ +------------------------------------------------------------------- +Wed Oct 30 09:01:32 UTC 2024 - Natnael Getahun + +- 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) + * Skip system token rotation in read-only APIs + * Enable RMT to handle the new dl.suse.com CDN domain (bsc#1234641) + ------------------------------------------------------------------- Wed Aug 21 15:28:43 UTC 2024 - Jesús Bermúdez Velázquez diff --git a/package/obs/rmt-server.spec b/package/obs/rmt-server.spec index 6aa6b9a2f..5e357fbbf 100644 --- a/package/obs/rmt-server.spec +++ b/package/obs/rmt-server.spec @@ -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/spec/factories/products.rb b/spec/factories/products.rb index 30532e1fc..4612dfb00 100644 --- a/spec/factories/products.rb +++ b/spec/factories/products.rb @@ -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 8fb81aa38..ceab7d02e 100644 --- a/spec/factories/systems.rb +++ b/spec/factories/systems.rb @@ -57,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/cli/smt_importer_spec.rb b/spec/lib/rmt/cli/smt_importer_spec.rb index d77404a52..17063bbc0 100644 --- a/spec/lib/rmt/cli/smt_importer_spec.rb +++ b/spec/lib/rmt/cli/smt_importer_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' require 'rmt/cli/smt_importer' -describe RMT::CLI::SMTImporter do +describe RMT::CLI::SMTImporter, :skip_sqlite do let(:data_dir) { File.join(Dir.pwd, 'spec/fixtures/files/csv') } let(:no_systems) { false } let(:importer) { described_class.new(data_dir, no_systems) } diff --git a/spec/lib/rmt/lockfile_spec.rb b/spec/lib/rmt/lockfile_spec.rb index d1b86f968..c06644bc3 100644 --- a/spec/lib/rmt/lockfile_spec.rb +++ b/spec/lib/rmt/lockfile_spec.rb @@ -3,7 +3,7 @@ RSpec.describe RMT::Lockfile do let(:lock_name) { nil } - describe '#lock' do + describe '#lock', :skip_sqlite do subject(:lock) { described_class.lock(lock_name) { nil } } context 'with an unnamed lock' do diff --git a/spec/lib/rmt/scc_spec.rb b/spec/lib/rmt/scc_spec.rb index 802cf1c8a..f1e3b9d61 100644 --- a/spec/lib/rmt/scc_spec.rb +++ b/spec/lib/rmt/scc_spec.rb @@ -238,7 +238,7 @@ described_class.new.sync end - it 'removes existing predecessor associations' do + it 'removes existing predecessor associations', :skip_sqlite do expect { ProductPredecessorAssociation.find(existing_association.id) }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -261,7 +261,18 @@ :repository, :with_products, auth_token: nil, - external_url: 'https://example.com/repos/not/updates.suse.com/' + external_url: 'https://installer-updates.suse.com/repos/not/updates', + installer_updates: true + ) + end + let!(:custom_repo) do + FactoryBot.create( + :repository, + :with_products, + auth_token: nil, + external_url: 'http://customer.com/stuff.suse.com/x86', + installer_updates: false, + scc_id: nil ) end @@ -297,6 +308,10 @@ def scc it 'other repos without auth_tokens persist' do expect { other_repo_without_token.reload }.not_to raise_error end + + it 'custom repos without auth_tokens persist' do + expect { custom_repo.reload }.not_to raise_error + end end describe '#export' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3c958ac15..3c49cd6fa 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -146,7 +146,7 @@ expect(friendly_id).to eq('my-repo-100000') end - it 'appends with unicode within the chain' do + it 'appends with unicode within the chain', :skip_sqlite do create(:repository, friendly_id: 'my-repo') create(:repository, friendly_id: 'my-repö-1') expect(friendly_id).to eq('my-repo-2') @@ -165,13 +165,13 @@ expect(friendly_id).to eq('dümmy-repö') end - it 'appends with unicode within the chain' do + it 'appends with unicode within the chain', :skip_sqlite do create(:repository, friendly_id: 'dummy-repo') create(:repository, friendly_id: 'dümmy-repö-1') expect(friendly_id).to eq('dümmy-repö-2') end - it 'correctly appends' do + it 'correctly appends', :skip_sqlite do create(:repository, friendly_id: 'dummy-repo') create(:repository, friendly_id: 'dummy-repo-1') expect(friendly_id).to eq('dümmy-repö-2') diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 5d71fea9f..9c8bffe57 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -58,6 +58,12 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + # Skipping some tests when running with (experimental) sqlite backend. + # Some tests / code parts are using specific mysql behavior + if ActiveRecord::Base.connection.adapter_name == 'SQLite' + config.filter_run_excluding skip_sqlite: true + end end Shoulda::Matchers.configure do |config| diff --git a/spec/requests/api/connect/base_controller_spec.rb b/spec/requests/api/connect/base_controller_spec.rb index 70ef9aaff..8832909ea 100644 --- a/spec/requests/api/connect/base_controller_spec.rb +++ b/spec/requests/api/connect/base_controller_spec.rb @@ -55,15 +55,6 @@ def require_product end end - shared_examples 'updates the system token' do - it 'updates the system token' do - allow(SecureRandom).to receive(:uuid).and_return(new_system_token) - - expect { get :service, params: { id: 1 } } - .to change { system.reload.system_token } - .from(current_system_token).to(new_system_token) - end - end shared_examples "does not update the old system's token" do it 'does not update the system token' do @@ -74,8 +65,6 @@ def require_product shared_examples 'creates a duplicate system' do it 'creates a new System (duplicate)' do - allow(SecureRandom).to receive(:uuid).and_return(new_system_token) - expect { get :service, params: { id: 1 } } .to change { System.get_by_credentials(system.login, system.password).count } .by(1) @@ -85,7 +74,6 @@ def require_product expect(duplicate_system).not_to eq(system) expect(duplicate_system.activations.count).to eq(system.activations.count) expect(duplicate_system.system_token).not_to eq(system.system_token) - expect(duplicate_system.system_token).to eq(new_system_token) end end @@ -182,8 +170,7 @@ def require_product let(:system) { create(:system, hostname: 'system') } include_examples 'does not create a duplicate system' - include_examples 'updates the system token' - include_examples 'responds with a new token' + include_examples "does not update the old system's token" end context 'when the system has a token and the header matches it' do @@ -193,8 +180,7 @@ def require_product let(:system) { create(:system, hostname: 'system', system_token: current_system_token) } include_examples 'does not create a duplicate system' - include_examples 'updates the system token' - include_examples 'responds with a new token' + include_examples "does not update the old system's token" end context 'when the system has a token and the header is blank' do @@ -208,7 +194,6 @@ def require_product include_examples "does not update the old system's token" include_examples 'creates a duplicate system' - include_examples 'responds with a new token' end context 'when the system has a token and the header does not match it' do diff --git a/spec/requests/api/connect/v3/systems/activations_controller_spec.rb b/spec/requests/api/connect/v3/systems/activations_controller_spec.rb index a4ff0a562..6a62ee2bd 100644 --- a/spec/requests/api/connect/v3/systems/activations_controller_spec.rb +++ b/spec/requests/api/connect/v3/systems/activations_controller_spec.rb @@ -69,5 +69,28 @@ expect(system.scc_synced_at).to be_nil end end + + context 'system token header' do + context 'when system token header is present in request' do + let(:token_headers) do + authenticated_headers.merge({ 'System-Token' => 'some_token' }) + end + + it 'sets system token in response headers' do + get url, headers: token_headers + expect(response.code).to eq '200' + expect(response.headers).to include('System-Token') + expect(response.headers['System-Token']).not_to be_nil + expect(response.headers['System-Token']).not_to be_empty + end + + it 'does not set system token header if no system token header in request' do + get url, headers: authenticated_headers + + expect(response.code).to eq '200' + expect(response.headers).not_to include('System-Token') + end + end + 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..9f6c47916 100644 --- a/spec/requests/api/connect/v3/systems/products_controller_spec.rb +++ b/spec/requests/api/connect/v3/systems/products_controller_spec.rb @@ -135,6 +135,26 @@ end end + shared_context 'activate with token in request headers' do + let(:payload) do + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + token: regcode + } + end + + before { post url, headers: { 'System-Token' => 'existing_token' }.merge(headers), params: payload } + subject do + Struct.new(:body, :code, :headers).new( + JSON.parse(response.body, symbolize_names: true), + response.status, + response.headers + ) + end + end + context 'unknown subscription' do include_context 'with subscriptions' let(:regcode) { 'NOT-EXISTING-SUBSCRIPTION' } @@ -173,6 +193,17 @@ expect(activation.product).to eq(product) end end + + context 'token update after activation is success' do + let(:subscription) { create :subscription, :with_products } + let(:product) { subscription.products.first } + let(:regcode) { subscription.regcode } + + include_context 'activate with token in request headers' + its(:code) { is_expected.to eq(201) } + its(:headers) { is_expected.to include('System-Token') } + its(:headers['System-Token']) { is_expected.not_to eq('existing_token') } + end end end @@ -225,6 +256,18 @@ its(:body) { is_expected.to eq(serialized_json) } end + describe 'response header should contain token' do + subject { response } + + let(:token_headers) do + headers.merge({ 'System-Token' => 'some_token' }) + end + + before { get url, headers: token_headers, params: payload } + its(:code) { is_expected.to eq('200') } + its(:headers) { is_expected.to include('System-Token') } + end + describe 'response with "-" in version' do subject { response } @@ -246,6 +289,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 } @@ -312,6 +382,18 @@ system.reload end + it 'calls refresh_system_token after upgrade action when system token header is present' do + put url, headers: headers.merge('System-Token' => 'test_token'), params: payload + expect(response.code).to eq('201') + expect(response.headers).to include('System-Token') + expect(response.headers['System-Token']).not_to eq('test_token') + end + + it 'No update in token after upgrade action when system token header is absent' do + put url, headers: headers, params: payload + expect(response.code).to eq('201') + expect(response.headers).not_to include('System-Token') + end context 'new product' do its(:code) { is_expected.to eq('201') } its(:body) { is_expected.to eq(serialized_json) } 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..91f73d4f4 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,43 @@ 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 + + context 'response header should contain token' do + let(:headers) { auth_header.merge('System-Token': 'existing-token') } + + it 'contains refreshed token in response' do + update_action + expect(response.headers).to include('System-Token') + expect(response.headers['System-Token']).not_to equal('existing-token') + end + end + + context 'response header should not contain token' do + let(:headers) { auth_header } + + it 'contains refreshed token in response' do + update_action + expect(response.headers).not_to include('System-Token') + end + end end describe '#deregister' do diff --git a/spec/requests/api/connect/v4/systems/products_controller_spec.rb b/spec/requests/api/connect/v4/systems/products_controller_spec.rb index 8bf9a64f4..6f960a941 100644 --- a/spec/requests/api/connect/v4/systems/products_controller_spec.rb +++ b/spec/requests/api/connect/v4/systems/products_controller_spec.rb @@ -140,4 +140,77 @@ end end end + + describe 'system token refresh' do + let(:system) { FactoryBot.create(:system, :with_activated_base_product) } + let(:headers) { auth_header.merge(version_header).merge('System-Token' => 'existing_token') } + + context 'token refresh for destroy action' do + let(:product) { FactoryBot.create(:product, :extension, :with_mirrored_repositories, :activated, system: system) } + let(:payload) { { identifier: product.identifier, version: product.version, arch: product.arch } } + + it 'refreshes system token when System-Token header is present' do + delete connect_systems_products_url, + headers: headers, + params: payload + + expect(response.status).to eq(200) + expect(response.headers).to include('System-Token') + expect(response.headers['System-Token']).not_to eq('existing_token') + end + + it 'does not refresh token when System-Token header is absent' do + headers_without_token = auth_header.merge(version_header) + expect_any_instance_of(described_class).not_to receive(:refresh_system_token) + + delete connect_systems_products_url, + headers: headers_without_token, + params: payload + + expect(response.status).to eq(200) + expect(response.headers).not_to include('System-Token') + end + end + + context 'token refresh for synchronize action' do + let(:path) { '/connect/systems/products/synchronize' } + + it 'refreshes system token when System-Token header is present' do + params = system.products.map do |product| + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + release_type: product.release_type + } + end + post path, + params: { products: params }, + headers: headers + + expect(response.status).to eq(200) + expect(response.headers).to include('System-Token') + expect(response.headers['System-Token']).not_to eq('existing_token') + end + + it 'does not refresh token when System-Token header is absent' do + headers_without_token = auth_header.merge(version_header) + + params = system.products.map do |product| + { + identifier: product.identifier, + version: product.version, + arch: product.arch, + release_type: product.release_type + } + end + post path, + params: { products: params }, + headers: headers_without_token + + expect(response.status).to eq(200) + expect(response.headers).not_to include('System-Token') + end + end + end end diff --git a/spec/services/repository_service_spec.rb b/spec/services/repository_service_spec.rb index 1387ea3b0..0035395b5 100644 --- a/spec/services/repository_service_spec.rb +++ b/spec/services/repository_service_spec.rb @@ -88,7 +88,7 @@ it('is not custom') { expect(repository.custom?).to eq(true) } - context 'already existing repositories with changing URL' do + context 'already existing repositories with changing URL', :skip_sqlite do subject(:repository) do service.create_repository!(product, url, attributes, custom: custom).reload url = 'https://foo.bar.com/bar/foo' 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)