From b48ae86b033d4ebd8441685d1c100989de696f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 4 Oct 2023 14:43:14 +0100 Subject: [PATCH 01/97] [service] Some renames in Software D-Bus API --- service/lib/agama/dbus/software/manager.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 67f1e5cbf7..78bf5ce0a1 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -58,16 +58,9 @@ def initialize(backend, logger) private_constant :SOFTWARE_INTERFACE dbus_interface SOFTWARE_INTERFACE do - dbus_reader :available_base_products, "a(ssa{sv})" + dbus_reader :available_products, "a(ssa{sv})" - dbus_reader :selected_base_product, "s" - - # documented way to be able to write to patterns and trigger signal - attr_writer :selected_patterns - - # selected patterns is hash with pattern name as id and 0 for user selected and - # 1 for auto selected. Can be extended in future e.g. for mandatory patterns - dbus_attr_reader :selected_patterns, "a{sy}" + dbus_reader :selected_product, "s" dbus_method :SelectProduct, "in ProductID:s" do |product_id| old_product_id = backend.product @@ -101,6 +94,13 @@ def initialize(backend, logger) ] end + # documented way to be able to write to patterns and trigger signal + attr_writer :selected_patterns + + # selected patterns is hash with pattern name as id and 0 for user selected and + # 1 for auto selected. Can be extended in future e.g. for mandatory patterns + dbus_attr_reader :selected_patterns, "a{sy}" + dbus_method(:AddPattern, "in id:s") { |p| backend.add_pattern(p) } dbus_method(:RemovePattern, "in id:s") { |p| backend.remove_pattern(p) } dbus_method(:SetUserPatterns, "in ids:as") { |ids| backend.user_patterns = ids } From 8bbd5673e3179677340567403720781cc664fef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 4 Oct 2023 14:43:44 +0100 Subject: [PATCH 02/97] [service] WIP Registration D-Bus API --- service/lib/agama/dbus/software/manager.rb | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 78bf5ce0a1..16e49a3f38 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -159,6 +159,45 @@ def finish busy_while { backend.finish } end + REGISTRATION_INTERFACE = "org.opensuse.Agama1.Registration" + private_constant :REGISTRATION_INTERFACE + + dbus_interface REGISTRATION_INTERFACE do + dbus_reader :reg_code, "s" + + dbus_reader :email, "s" + + dbus_reader :state, "u" + + dbus_method :Register, "in reg_code:s, in options:a{sv}, out result:u" do + |reg_code, options| + backend.registration.register(reg_code, email: options["Email"]) + # map errors to exit codes? + 0 + end + + dbus_method :Deregister, "out result:u" do + backend.registration.deregister + # map errors to exit codes? + 0 + end + end + + def reg_code + backend.registration.reg_code || "" + end + + def email + backend.registration.email || "" + end + + # Replace #State by #IsDisabled and #isOptional ? + def state + return 0 if backend.registration.disabled? + return 1 if backend.registration.optional? + return 2 unless backend.registration.optional? + end + private # @return [Agama::Software] From c39c3768bb26deca434da63f279a2b075663f624 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 4 Oct 2023 17:16:11 +0200 Subject: [PATCH 03/97] add registration mock class --- service/lib/agama/registration.rb | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 service/lib/agama/registration.rb diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb new file mode 100644 index 0000000000..e121b421fe --- /dev/null +++ b/service/lib/agama/registration.rb @@ -0,0 +1,43 @@ +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + # Handles everything related to registration of system to SCC, RMT or similar + class Registration + attr_reader :reg_code + attr_reader :email + + # initializes registration with instance of software manager for query about products + def initialize(software_manager) + @software = software_manager + end + + def register(code, email: nil) + end + + def deregister + end + + def disabled? + end + + def optional? + end + end +end From e50337b05061538de788d02aa4248ae22886e1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 5 Oct 2023 15:03:33 +0100 Subject: [PATCH 04/97] [service] Replace Validation by Issues interface --- service/lib/agama/dbus/clients/software.rb | 10 +-- service/lib/agama/dbus/software/manager.rb | 21 +++--- service/lib/agama/manager.rb | 2 +- service/lib/agama/software/manager.rb | 81 ++++++++++++++++------ service/lib/agama/software/proposal.rb | 36 +++++----- 5 files changed, 98 insertions(+), 52 deletions(-) diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb index 8b953d705d..f2191118b7 100644 --- a/service/lib/agama/dbus/clients/software.rb +++ b/service/lib/agama/dbus/clients/software.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,18 +20,18 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/base" -require "agama/dbus/clients/with_service_status" +require "agama/dbus/clients/with_issues" require "agama/dbus/clients/with_progress" -require "agama/dbus/clients/with_validation" +require "agama/dbus/clients/with_service_status" module Agama module DBus module Clients # D-Bus client for software configuration class Software < Base - include WithServiceStatus + include WithIssues include WithProgress - include WithValidation + include WithServiceStatus TYPES = [:package, :pattern].freeze private_constant :TYPES diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 16e49a3f38..26ba2d368e 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -21,12 +21,12 @@ require "dbus" require "agama/dbus/base_object" -require "agama/dbus/with_service_status" require "agama/dbus/clients/locale" require "agama/dbus/clients/network" +require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" -require "agama/dbus/interfaces/validation" +require "agama/dbus/with_service_status" module Agama module DBus @@ -36,7 +36,7 @@ class Manager < BaseObject include WithServiceStatus include Interfaces::Progress include Interfaces::ServiceStatus - include Interfaces::Validation + include Interfaces::Issues PATH = "/org/opensuse/Agama/Software1" private_constant :PATH @@ -54,6 +54,13 @@ def initialize(backend, logger) @selected_patterns = {} end + # List of issues, see {DBus::Interfaces::Issues} + # + # @return [Array] + def issues + backend.issues + end + SOFTWARE_INTERFACE = "org.opensuse.Agama.Software1" private_constant :SOFTWARE_INTERFACE @@ -73,7 +80,6 @@ def initialize(backend, logger) logger.info "Selecting product #{product_id}" select_product(product_id) dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedBaseProduct" => product_id }, []) - update_validation # as different product means different software selection end # value of result hash is category, description, icon, summary and order @@ -140,13 +146,10 @@ def select_product(product_id) def probe busy_while { backend.probe } - - update_validation # probe do force proposal end def propose busy_while { backend.propose } - update_validation nil # explicit nil as return value end @@ -218,6 +221,8 @@ def register_callbacks backend.on_selected_patterns_change do self.selected_patterns = compute_patterns end + + backend.on_issues_change { issues_properties_changed } end USER_SELECTED_PATTERN = 0 diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 4f445aad79..b11b5960cb 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -200,7 +200,7 @@ def on_services_status_change(&block) # # @return [Boolean] def valid? - [users, software].all?(&:valid?) && !storage.errors? + users.valid? && !software.errors? && !storage.errors? end # Collects the logs and stores them into an archive diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index e1bcd916b8..20076c732e 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2021] SUSE LLC +# Copyright (c) [2021-2023] SUSE LLC # # All Rights Reserved. # @@ -19,18 +19,19 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "yast" require "fileutils" -require "agama/config" -require "agama/helpers" -require "agama/with_progress" -require "agama/validation_error" +require "yast" +require "yast2/arch_filter" require "y2packager/product" require "y2packager/resolvable" -require "yast2/arch_filter" +require "agama/config" +require "agama/helpers" +require "agama/issue" require "agama/software/callbacks" require "agama/software/proposal" require "agama/software/repositories_manager" +require "agama/with_progress" +require "agama/with_issues" Yast.import "Package" Yast.import "Packages" @@ -43,6 +44,7 @@ module Software # This class is responsible for software handling class Manager include Helpers + include WithIssues include WithProgress GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" @@ -78,6 +80,7 @@ def initialize(config, logger) # patterns selected by user @user_patterns = [] @selected_patterns_change_callbacks = [] + update_issues end def select_product(name) @@ -87,6 +90,7 @@ def select_product(name) @config.pick_product(name) @product = name repositories.delete_all + update_issues end def probe @@ -109,6 +113,7 @@ def probe progress.step("Calculating the software proposal") { propose } Yast::Stage.Set("initial") + update_issues end def initialize_target_repos @@ -122,25 +127,11 @@ def propose proposal.languages = languages select_resolvables result = proposal.calculate + update_issues logger.info "Proposal result: #{result.inspect}" result end - # Returns the errors related to the software proposal - # - # * Repositories that could not be probed are reported as errors. - # * If none of the repositories could be probed, do not report missing - # patterns and/or packages. Those issues does not make any sense if there - # are no repositories to install from. - def validate - errors = repositories.disabled.map do |repo| - ValidationError.new("Could not read the repository #{repo.name}") - end - return errors if repositories.enabled.empty? - - errors + proposal.errors - end - # Installs the packages to the target system def install steps = proposal.packages_count @@ -346,6 +337,52 @@ def select_resolvables def selected_patterns_changed @selected_patterns_change_callbacks.each(&:call) end + + # Updates the list of issues. + def update_issues + self.issues = current_issues + end + + # List of current issues. + # + # @return [Array] + def current_issues + return [missing_product_issue] unless product + + issues = repos_issues + + # If none of the repositories could be probed, then do not report missing patterns and/or + # packages. Those issues does not make any sense if there are no repositories to install + # from. + issues += proposal.issues if repositories.enabled.any? + + # TODO + # issues += registration.issues + + issues + end + + # Issue when a product is missing + # + # @return [Agama::Issue] + def missing_product_issue + Issue.new("Product not selected yet", + source: Issue::Source::CONFIG, + severity: Issue::Severity::ERROR) + end + + # Issues related to the software proposal. + # + # Repositories that could not be probed are reported as errors. + # + # @return [Array] + def repos_issues + issues = repositories.disabled.map do |repo| + Issue.new("Could not read the repository #{repo.name}", + source: Issue::Source::SYSTEM, + severity: Issue::Severity::WARN) + end + end end end end diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index c4310967c2..def5bc1e7b 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,7 +20,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/validation_error" +require "agama/issue" Yast.import "Stage" Yast.import "Installation" @@ -47,7 +47,7 @@ module Software # proposal.add_resolvables("agama", :pattern, ["enhanced_base"]) # proposal.languages = ["en_US", "de_DE"] # proposal.calculate #=> true - # proposal.errors #=> [] + # proposal.issues #=> [] class Proposal # @return [String,nil] Base product attr_accessor :base_product @@ -55,15 +55,15 @@ class Proposal # @return [Array] List of languages to install attr_accessor :languages - # @return [Array] List of errors from the calculated proposal - attr_reader :errors + # @return [Array] List of issues from the calculated proposal + attr_reader :issues # Constructor # # @param logger [Logger] def initialize(logger: nil) @logger = logger || Logger.new($stdout) - @errors = [] + @issues = [] @base_product = nil @calculated = false end @@ -84,14 +84,14 @@ def set_resolvables(unique_id, type, resolvables, optional: false) # # @return [Boolean] def calculate - @errors.clear + @issues.clear initialize_target select_base_product proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true) solve_dependencies @calculated = true - @errors = find_errors(proposal) + @issues = find_issues(proposal) valid? end @@ -115,7 +115,7 @@ def packages_size # # @return [Boolean] def valid? - @calculated && @errors.empty? + @calculated && @issues.empty? end private @@ -148,21 +148,25 @@ def select_base_product end end - # Returns the errors from the attempt to create a proposal + # Returns the issues from the attempt to create a proposal. # - # It collects errors from: + # It collects issues from: # - # * The proposal result - # * The last solver execution + # * The proposal result. + # * The last solver execution. # # @param proposal_result [Hash] Proposal result; it might contain a "warning" key with warning # messages. - # @return [Array] List of errors - def find_errors(proposal_result) + # @return [Array] List of issues. + def find_issues(proposal_result) msgs = [] msgs.concat(warning_messages(proposal_result)) msgs.concat(solver_messages) - msgs.map { |m| ValidationError.new(m) } + msgs.map do |msg| + Issue.new(msg, + source: Issue::Source::CONFIG, + severity: Issue::Severity::WARN) + end end # Runs the solver to satisfy the solve_dependencies From c5535f774bb3b763270c299d8de2192d73aef4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 5 Oct 2023 15:04:09 +0100 Subject: [PATCH 05/97] [service] Fix names --- service/lib/agama/dbus/clients/software.rb | 8 ++++---- service/lib/agama/dbus/software/manager.rb | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb index f2191118b7..5d9321698a 100644 --- a/service/lib/agama/dbus/clients/software.rb +++ b/service/lib/agama/dbus/clients/software.rb @@ -55,7 +55,7 @@ def service_name # # @return [Array>] name and display name of each product def available_products - dbus_object["org.opensuse.Agama.Software1"]["AvailableBaseProducts"].map do |l| + dbus_object["org.opensuse.Agama.Software1"]["AvailableProducts"].map do |l| l[0..1] end end @@ -64,7 +64,7 @@ def available_products # # @return [String, nil] name of the product def selected_product - product = dbus_object["org.opensuse.Agama.Software1"]["SelectedBaseProduct"] + product = dbus_object["org.opensuse.Agama.Software1"]["SelectedProduct"] return nil if product.empty? product @@ -170,8 +170,8 @@ def remove_resolvables(unique_id, type, resolvables, optional: false) # @param block [Proc] Callback to run when a product is selected def on_product_selected(&block) on_properties_change(dbus_object) do |_, changes, _| - base_product = changes["SelectedBaseProduct"] - block.call(base_product) unless base_product.nil? + product = changes["SelectedProduct"] + block.call(product) unless product.nil? end end diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 26ba2d368e..9aed6a01ef 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -127,7 +127,7 @@ def issues dbus_method(:Finish) { finish } end - def available_base_products + def available_products backend.products.map do |id, data| [id, data["name"], { "description" => data["description"] }].freeze end @@ -136,7 +136,7 @@ def available_base_products # Returns the selected base product # # @return [String] Product ID or an empty string if no product is selected - def selected_base_product + def selected_product backend.product || "" end From 851e85f8a2c37fa59056df5f83ad1d2f0d0c080c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 6 Oct 2023 11:49:55 +0100 Subject: [PATCH 06/97] [service] WIP return error number and message --- service/lib/agama/dbus/software/manager.rb | 62 ++++++++++++++++------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 9aed6a01ef..9ae11ab3bd 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "dbus" +require "suse/connect" require "agama/dbus/base_object" require "agama/dbus/clients/locale" require "agama/dbus/clients/network" @@ -166,24 +167,17 @@ def finish private_constant :REGISTRATION_INTERFACE dbus_interface REGISTRATION_INTERFACE do - dbus_reader :reg_code, "s" + dbus_reader(:reg_code, "s") - dbus_reader :email, "s" + dbus_reader(:email, "s") - dbus_reader :state, "u" + dbus_reader(:state, "u") - dbus_method :Register, "in reg_code:s, in options:a{sv}, out result:u" do - |reg_code, options| - backend.registration.register(reg_code, email: options["Email"]) - # map errors to exit codes? - 0 + dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args| + [register(*args)] end - dbus_method :Deregister, "out result:u" do - backend.registration.deregister - # map errors to exit codes? - 0 - end + dbus_method(:Deregister, "out result:(us)") { [deregister] } end def reg_code @@ -194,11 +188,21 @@ def email backend.registration.email || "" end - # Replace #State by #IsDisabled and #isOptional ? def state - return 0 if backend.registration.disabled? - return 1 if backend.registration.optional? - return 2 unless backend.registration.optional? + # TODO + 0 + end + + def register(reg_code, options) + connect_result do + backend.registration.register(reg_code, email: options["Email"]) + end + end + + def deregister + connect_result do + backend.registration.deregister + end end private @@ -235,6 +239,30 @@ def compute_patterns patterns end + + # @return [Array] + def connect_result(&block) + block.call + [0, ""] + rescue SocketError => e + logger.error("Network error: #{e}") + [1, "Connection to registration server failed (network error)"] + rescue Timeout::Error => e + logger.error("Timeout error: #{e}") + [2, "Connection to registration server failed (timeout)"] + rescue SUSE::Connect::ApiError => e + [3, "Connection to registration server failed"] + rescue SUSE::Connect::MissingSccCredentialsFile => e + [4, "Connection to registration server failed (missing credentials)"] + rescue SUSE::Connect::MalformedSccCredentialsFile => e + [5, "Connection to registration server failed (incorrect credentials)"] + rescue OpenSSL::SSL::SSLError => e + [6, "Connection to registration server failed (invalid certificate)"] + rescue JSON::ParserError => e + [7, "Connection to registration server failed"] + rescue StandardError => e + [8, "Connection to registration server failed"] + end end end end From 9300a72c88c3829169c6c60e71b36560c1137119 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 6 Oct 2023 13:09:37 +0200 Subject: [PATCH 07/97] initial forst working POC --- service/lib/agama/registration.rb | 30 ++++++++++++++++++++++++++- service/lib/agama/software/manager.rb | 4 ++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index e121b421fe..e145d7275d 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -17,6 +17,12 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "yast" +require "openstruct" + +require "registration/registration" +require "y2packager/new_repository_setup" + module Agama # Handles everything related to registration of system to SCC, RMT or similar class Registration @@ -26,9 +32,26 @@ class Registration # initializes registration with instance of software manager for query about products def initialize(software_manager) @software = software_manager + @on_state_change_callbacks = [] end - def register(code, email: nil) + def register(code, email: "") + target_distro = "ALP-Dolomite-1-x86_64" # TODO read it + registration = Registration::Registration.new # intentional no url yet + registration.register(email, code, target_distro) + # TODO: fill it properly for scc + target_product = OpenStruct.new( + arch: "x86_64", + identifier: "ALP-Dolomite", + version: "1.0", + release_type: "ALPHA" + ) + activate_params = {} + service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) + Y2Packager::NewRepositorySetup.instance.add_service(service.name) + + @reg_code = code + @email = email end def deregister @@ -39,5 +62,10 @@ def disabled? def optional? end + + # callback when state changed like when different product is selected + def on_state_change(&block) + @on_state_change_callbacks << block + end end end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 20076c732e..6721fd489f 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -252,6 +252,10 @@ def used_disk_space Yast::String.FormatSizeWithPrecision(proposal.packages_size, 1, true) end + def registration + @registration ||= Registration.new(self) + end + private def proposal From 12102fdbe9d8c9c078a9a293c2649d5f2cbb634e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 9 Oct 2023 11:52:48 +0100 Subject: [PATCH 08/97] [service] More fixes to adapt to changes in D-Bus API --- service/lib/agama/dbus/software/manager.rb | 4 +-- service/lib/agama/dbus/software/proposal.rb | 15 +------- service/lib/agama/dbus/software_service.rb | 13 ++----- service/lib/agama/software/manager.rb | 2 +- service/lib/agama/software/proposal.rb | 40 +++++++++++---------- service/lib/agama/with_issues.rb | 8 +++++ 6 files changed, 36 insertions(+), 46 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 9ae11ab3bd..a9fc91dd4c 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -80,7 +80,7 @@ def issues logger.info "Selecting product #{product_id}" select_product(product_id) - dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedBaseProduct" => product_id }, []) + dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedProduct" => product_id }, []) end # value of result hash is category, description, icon, summary and order @@ -260,8 +260,6 @@ def connect_result(&block) [6, "Connection to registration server failed (invalid certificate)"] rescue JSON::ParserError => e [7, "Connection to registration server failed"] - rescue StandardError => e - [8, "Connection to registration server failed"] end end end diff --git a/service/lib/agama/dbus/software/proposal.rb b/service/lib/agama/dbus/software/proposal.rb index 13cd847d0a..cd0e3587d0 100644 --- a/service/lib/agama/dbus/software/proposal.rb +++ b/service/lib/agama/dbus/software/proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -46,8 +46,6 @@ class Proposal < ::DBus::Object # @param logger [Logger] def initialize(logger) @logger = logger - @on_change_callbacks = [] - super(PATH) end @@ -55,7 +53,6 @@ def initialize(logger) dbus_method :AddResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.AddResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end dbus_method :GetResolvables, @@ -66,28 +63,18 @@ def initialize(logger) dbus_method :SetResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.SetResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end dbus_method :RemoveResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.RemoveResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end end - def on_change(&block) - @on_change_callbacks << block - end - private # @return [Logger] attr_reader :logger - - def notify_change! - @on_change_callbacks.each(&:call) - end end end end diff --git a/service/lib/agama/dbus/software_service.rb b/service/lib/agama/dbus/software_service.rb index 01f573aad8..a2d7100ec9 100644 --- a/service/lib/agama/dbus/software_service.rb +++ b/service/lib/agama/dbus/software_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -82,17 +82,10 @@ def service # @return [Array<::DBus::Object>] def dbus_objects @dbus_objects ||= [ - dbus_software_manager, - Agama::DBus::Software::Proposal.new(logger).tap do |proposal| - proposal.on_change { dbus_software_manager.update_validation } - end + Agama::DBus::Software::Manager.new(@backend, logger), + Agama::DBus::Software::Proposal.new(logger) ] end - - # @return [Agama::DBus::Software::Manager] - def dbus_software_manager - @dbus_software_manager ||= Agama::DBus::Software::Manager.new(@backend, logger) - end end end end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 6721fd489f..00d974fafc 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -384,7 +384,7 @@ def repos_issues issues = repositories.disabled.map do |repo| Issue.new("Could not read the repository #{repo.name}", source: Issue::Source::SYSTEM, - severity: Issue::Severity::WARN) + severity: Issue::Severity::ERROR) end end end diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index def5bc1e7b..45470fc593 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -21,6 +21,7 @@ require "yast" require "agama/issue" +require "agama/with_issues" Yast.import "Stage" Yast.import "Installation" @@ -49,23 +50,20 @@ module Software # proposal.calculate #=> true # proposal.issues #=> [] class Proposal + include WithIssues + # @return [String,nil] Base product attr_accessor :base_product # @return [Array] List of languages to install attr_accessor :languages - # @return [Array] List of issues from the calculated proposal - attr_reader :issues - # Constructor # # @param logger [Logger] def initialize(logger: nil) @logger = logger || Logger.new($stdout) - @issues = [] @base_product = nil - @calculated = false end # Adds the given list of resolvables to the proposal @@ -84,14 +82,12 @@ def set_resolvables(unique_id, type, resolvables, optional: false) # # @return [Boolean] def calculate - @issues.clear initialize_target select_base_product - proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true) + @proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true) solve_dependencies - @calculated = true - @issues = find_issues(proposal) + update_issues valid? end @@ -115,7 +111,7 @@ def packages_size # # @return [Boolean] def valid? - @calculated && @issues.empty? + proposal && !errors? end private @@ -123,6 +119,11 @@ def valid? # @return [Logger] attr_reader :logger + # Proposal result + # + # @return [Hash, nil] nil if not calculated yet. + attr_reader :proposal + # Initializes the target, closing the previous one def initialize_target Yast::Pkg.TargetFinish # ensure that previous target is closed @@ -148,25 +149,28 @@ def select_base_product end end - # Returns the issues from the attempt to create a proposal. + # Updates the issues from the attempt to create a proposal. # # It collects issues from: # # * The proposal result. # * The last solver execution. # - # @param proposal_result [Hash] Proposal result; it might contain a "warning" key with warning - # messages. - # @return [Array] List of issues. - def find_issues(proposal_result) + # @return [Array] + def update_issues + self.issues = [] + return unless proposal + msgs = [] - msgs.concat(warning_messages(proposal_result)) + msgs.concat(warning_messages(proposal)) msgs.concat(solver_messages) - msgs.map do |msg| + issues = msgs.map do |msg| Issue.new(msg, source: Issue::Source::CONFIG, - severity: Issue::Severity::WARN) + severity: Issue::Severity::ERROR) end + + self.issues = issues end # Runs the solver to satisfy the solve_dependencies diff --git a/service/lib/agama/with_issues.rb b/service/lib/agama/with_issues.rb index 950ddce6bb..d682200760 100644 --- a/service/lib/agama/with_issues.rb +++ b/service/lib/agama/with_issues.rb @@ -29,6 +29,14 @@ def issues @issues || [] end + def errors + issues.select(&:error?) + end + + def errors? + errors.any? + end + # Sets the list of current issues # # @param issues [Array] From 78c4df01fa4c511c095f52896c1ed9c09d9e4761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 9 Oct 2023 11:58:11 +0100 Subject: [PATCH 09/97] [web] Adapt UI to D-Bus API changes --- web/src/client/software.js | 14 +++++++------- web/src/client/software.test.js | 4 ++-- web/src/components/overview/SoftwareSection.jsx | 4 ++-- .../components/overview/SoftwareSection.test.jsx | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index cc6328eb23..0efbb6c07c 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -22,7 +22,7 @@ // @ts-check import DBusClient from "./dbus"; -import { WithStatus, WithProgress, WithValidation } from "./mixins"; +import { WithIssues, WithStatus, WithProgress } from "./mixins"; const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1"; const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; @@ -65,7 +65,7 @@ class SoftwareBaseClient { */ async getProducts() { const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.AvailableBaseProducts.map(product => { + return proxy.AvailableProducts.map(product => { const [id, name, meta] = product; return { id, name, description: meta.description?.v }; }); @@ -89,10 +89,10 @@ class SoftwareBaseClient { async getSelectedProduct() { const products = await this.getProducts(); const proxy = await this.client.proxy(SOFTWARE_IFACE); - if (proxy.SelectedBaseProduct === "") { + if (proxy.SelectedProduct === "") { return null; } - return products.find(product => product.id === proxy.SelectedBaseProduct); + return products.find(product => product.id === proxy.SelectedProduct); } /** @@ -112,8 +112,8 @@ class SoftwareBaseClient { */ onProductChange(handler) { return this.client.onObjectChanged(SOFTWARE_PATH, SOFTWARE_IFACE, changes => { - if ("SelectedBaseProduct" in changes) { - const selected = changes.SelectedBaseProduct.v.toString(); + if ("SelectedProduct" in changes) { + const selected = changes.SelectedProduct.v.toString(); handler(selected); } }); @@ -123,7 +123,7 @@ class SoftwareBaseClient { /** * Allows getting the list the available products and selecting one for installation. */ -class SoftwareClient extends WithValidation( +class SoftwareClient extends WithIssues( WithProgress( WithStatus(SoftwareBaseClient, SOFTWARE_PATH), SOFTWARE_PATH ), SOFTWARE_PATH diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index f8460beb43..5e12a2ff54 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -30,11 +30,11 @@ const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; const softProxy = { wait: jest.fn(), - AvailableBaseProducts: [ + AvailableProducts: [ ["MicroOS", "openSUSE MicroOS", {}], ["Tumbleweed", "openSUSE Tumbleweed", {}] ], - SelectedBaseProduct: "MicroOS" + SelectedProduct: "MicroOS" }; beforeEach(() => { diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx index f8f93bc8fb..cc2ab451b4 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.jsx @@ -83,7 +83,7 @@ export default function SoftwareSection({ showErrors }) { useEffect(() => { const updateProposal = async () => { - const errors = await cancellablePromise(client.getValidationErrors()); + const errors = await cancellablePromise(client.getIssues()); const size = await cancellablePromise(client.getUsedSpace()); dispatch({ type: "UPDATE_PROPOSAL", payload: { errors, size } }); @@ -155,7 +155,7 @@ export default function SoftwareSection({ showErrors }) { title={_("Software")} icon="apps" loading={state.busy} - errors={errors} + errors={errors.map(e => ({ message: e.description }))} > diff --git a/web/src/components/overview/SoftwareSection.test.jsx b/web/src/components/overview/SoftwareSection.test.jsx index 1299e09bda..d21df0e84c 100644 --- a/web/src/components/overview/SoftwareSection.test.jsx +++ b/web/src/components/overview/SoftwareSection.test.jsx @@ -31,7 +31,7 @@ jest.mock("~/client"); let getStatusFn = jest.fn().mockResolvedValue(IDLE); let getProgressFn = jest.fn().mockResolvedValue({}); -let getValidationErrorsFn = jest.fn().mockResolvedValue([]); +let getIssuesFn = jest.fn().mockResolvedValue([]); beforeEach(() => { createClient.mockImplementation(() => { @@ -39,7 +39,7 @@ beforeEach(() => { software: { getStatus: getStatusFn, getProgress: getProgressFn, - getValidationErrors: getValidationErrorsFn, + getIssues: getIssuesFn, onStatusChange: noop, onProgressChange: noop, getUsedSpace: jest.fn().mockResolvedValue("500 MB") @@ -48,7 +48,7 @@ beforeEach(() => { }); }); -describe("when there proposal is calculated", () => { +describe("when the proposal is calculated", () => { beforeEach(() => { getStatusFn = jest.fn().mockResolvedValue(IDLE); }); @@ -61,7 +61,7 @@ describe("when there proposal is calculated", () => { describe("and there are errors", () => { beforeEach(() => { - getValidationErrorsFn = jest.fn().mockResolvedValue([{ message: "Could not install..." }]); + getIssuesFn = jest.fn().mockResolvedValue([{ description: "Could not install..." }]); }); it("renders a button to refresh the repositories", async () => { From 8ac969991f27b388dd72069656f8bdf0457605a1 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 9 Oct 2023 13:37:30 +0200 Subject: [PATCH 10/97] use suse connect directly without yast --- service/lib/agama/registration.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index e145d7275d..4d9783f996 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -18,9 +18,9 @@ # find current contact information at www.suse.com. require "yast" -require "openstruct" +require "ostruct" +require "suse/connect" -require "registration/registration" require "y2packager/new_repository_setup" module Agama @@ -37,7 +37,16 @@ def initialize(software_manager) def register(code, email: "") target_distro = "ALP-Dolomite-1-x86_64" # TODO read it - registration = Registration::Registration.new # intentional no url yet + connect_params = { + token: code, + email: email + } + + login, password = SUSE::Connect::YaST.announce_system(connect_params, distro_target) + # write the global credentials + # TODO: check if we can do it in memory for libzypp + SUSE::Connect::YaST.create_credentials_file(login, password) + registration.register(email, code, target_distro) # TODO: fill it properly for scc target_product = OpenStruct.new( From cdc9bb4069e7d16971fbe49efc1509d8d16d438a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 9 Oct 2023 17:02:53 +0100 Subject: [PATCH 11/97] [service] Work on some tests --- .../test/agama/dbus/clients/software_test.rb | 8 +- .../test/agama/dbus/software/manager_test.rb | 80 ++++++----- service/test/agama/manager_test.rb | 5 +- service/test/agama/software/manager_test.rb | 126 ++++++++++++------ 4 files changed, 133 insertions(+), 86 deletions(-) diff --git a/service/test/agama/dbus/clients/software_test.rb b/service/test/agama/dbus/clients/software_test.rb index c6ab60127c..c050e71ce7 100644 --- a/service/test/agama/dbus/clients/software_test.rb +++ b/service/test/agama/dbus/clients/software_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" +require_relative "with_issues_examples" require_relative "with_service_status_examples" require_relative "with_progress_examples" require "agama/dbus/clients/software" @@ -53,7 +54,7 @@ describe "#available_products" do before do - allow(software_iface).to receive(:[]).with("AvailableBaseProducts").and_return( + allow(software_iface).to receive(:[]).with("AvailableProducts").and_return( [ ["Tumbleweed", "openSUSE Tumbleweed", {}], ["Leap15.3", "openSUSE Leap 15.3", {}] @@ -71,7 +72,7 @@ describe "#selected_product" do before do - allow(software_iface).to receive(:[]).with("SelectedBaseProduct").and_return(product) + allow(software_iface).to receive(:[]).with("SelectedProduct").and_return(product) end context "when there is no selected product" do @@ -180,6 +181,7 @@ end end + include_examples "issues" include_examples "service status" include_examples "progress" end diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index f6eda1223b..edc0ae1946 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,10 +20,13 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require "agama/dbus/software/manager" +require "agama/config" +require "agama/dbus/clients/locale" +require "agama/dbus/clients/network" +require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" -require "agama/dbus/interfaces/validation" +require "agama/dbus/software/manager" require "agama/software" describe Agama::DBus::Software::Manager do @@ -31,7 +34,15 @@ let(:logger) { Logger.new($stdout, level: :warn) } - let(:backend) { instance_double(Agama::Software::Manager) } + let(:backend) { Agama::Software::Manager.new(config, logger) } + + let(:config) do + Agama::Config.new(YAML.safe_load(File.read(config_path))) + end + + let(:config_path) do + File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") + end let(:progress_interface) { Agama::DBus::Interfaces::Progress::PROGRESS_INTERFACE } @@ -39,42 +50,44 @@ Agama::DBus::Interfaces::ServiceStatus::SERVICE_STATUS_INTERFACE end - let(:validation_interface) { Agama::DBus::Interfaces::Validation::VALIDATION_INTERFACE } + let(:issues_interface) { Agama::DBus::Interfaces::Issues::ISSUES_INTERFACE } before do - allow_any_instance_of(described_class).to receive(:register_callbacks) - allow_any_instance_of(described_class).to receive(:register_progress_callbacks) - allow_any_instance_of(described_class).to receive(:register_service_status_callbacks) + allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale_client) + allow(Agama::DBus::Clients::Network).to receive(:new).and_return(network_client) + allow(backend).to receive(:probe) + allow(backend).to receive(:propose) + allow(backend).to receive(:install) + allow(backend).to receive(:finish) + allow(subject).to receive(:dbus_properties_changed) end - it "defines Progress D-Bus interface" do - expect(subject.intfs.keys).to include(progress_interface) + let(:locale_client) do + instance_double(Agama::DBus::Clients::Locale, on_language_selected: nil) end - it "defines ServiceStatus D-Bus interface" do - expect(subject.intfs.keys).to include(service_status_interface) + let(:network_client) do + instance_double(Agama::DBus::Clients::Network, on_connection_changed: nil) end - it "defines Validation D-Bus interface" do - expect(subject.intfs.keys).to include(validation_interface) + it "defines Issues D-Bus interface" do + expect(subject.intfs.keys).to include(issues_interface) + end + + it "defines Progress D-Bus interface" do + expect(subject.intfs.keys).to include(progress_interface) end - it "configures callbacks from Progress interface" do - expect_any_instance_of(described_class).to receive(:register_progress_callbacks) - subject + it "defines ServiceStatus D-Bus interface" do + expect(subject.intfs.keys).to include(service_status_interface) end - it "configures callbacks from ServiceStatus interface" do - expect_any_instance_of(described_class).to receive(:register_service_status_callbacks) - subject + it "emits signal when issues changes" do + expect(subject).to receive(:issues_properties_changed) + backend.issues = [] end describe "#probe" do - before do - allow(subject).to receive(:update_validation) - allow(backend).to receive(:probe) - end - it "runs the probing, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) expect(backend).to receive(:probe) @@ -82,20 +95,9 @@ subject.probe end - - it "updates validation" do - expect(subject).to receive(:update_validation) - - subject.probe - end end describe "#propose" do - before do - allow(subject).to receive(:update_validation) - allow(backend).to receive(:propose) - end - it "calculates the proposal, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) expect(backend).to receive(:propose) @@ -103,12 +105,6 @@ subject.propose end - - it "updates validation" do - expect(subject).to receive(:update_validation) - - subject.propose - end end describe "#install" do diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index e63d6595f4..9e465df938 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -25,6 +25,7 @@ require "agama/config" require "agama/question" require "agama/dbus/service_status" +require "agama/users" describe Agama::Manager do subject { described_class.new(config, logger) } @@ -42,7 +43,7 @@ instance_double( Agama::DBus::Clients::Software, probe: nil, install: nil, propose: nil, finish: nil, on_product_selected: nil, - on_service_status_change: nil, selected_product: product, valid?: true + on_service_status_change: nil, selected_product: product, errors?: false ) end let(:users) do @@ -202,7 +203,7 @@ context "when the software configuration is not valid" do before do - allow(software).to receive(:valid?).and_return(false) + allow(software).to receive(:errors?).and_return(true) end it "returns false" do diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 1dd557a6f1..befa2c1787 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -20,12 +20,15 @@ # find current contact information at www.suse.com. require_relative "../../test_helper" +require_relative "../with_issues_examples" require_relative "../with_progress_examples" require_relative File.join( SRC_PATH, "agama", "dbus", "y2dir", "software", "modules", "PackageCallbacks.rb" ) require "agama/config" +require "agama/issue" require "agama/software/manager" +require "agama/software/proposal" require "agama/dbus/clients/questions" describe Agama::Software::Manager do @@ -35,15 +38,19 @@ let(:base_url) { "" } let(:destdir) { "/mnt" } let(:gpg_keys) { [] } + let(:repositories) do instance_double( Agama::Software::RepositoriesManager, add: nil, load: nil, delete_all: nil, - empty?: true + empty?: true, + enabled: enabled_repos, + disabled: disabled_repos ) end + let(:proposal) do instance_double( Agama::Software::Proposal, @@ -51,10 +58,15 @@ calculate: nil, :languages= => nil, set_resolvables: nil, - packages_count: "500 MB" + packages_count: "500 MB", + issues: proposal_issues ) end + let(:enabled_repos) { [] } + let(:disabled_repos) { [] } + let(:proposal_issues) { [] } + let(:config_path) do File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") end @@ -81,6 +93,74 @@ allow(Agama::Software::Proposal).to receive(:new).and_return(proposal) end + shared_examples "software issues" do |tested_method| + before do + allow(subject).to receive(:product).and_return("Tumbleweed") + end + + let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] } + + context "if there is no product selected yet" do + before do + allow(subject).to receive(:product).and_return(nil) + end + + it "sets an issue" do + subject.public_send(tested_method) + + expect(subject.issues).to contain_exactly(an_object_having_attributes( + description: /product not selected/i + )) + end + end + + context "if there are disabled repositories" do + let(:disabled_repos) do + [ + instance_double(Agama::Software::Repository, name: "Repo #1"), + instance_double(Agama::Software::Repository, name: "Repo #2") + ] + end + + it "adds an issue for each disabled repository" do + subject.public_send(tested_method) + + expect(subject.issues).to include( + an_object_having_attributes( + description: /could not read the repository Repo #1/i + ), + an_object_having_attributes( + description: /could not read the repository Repo #2/i + ) + ) + end + end + + context "if there is any enabled repository" do + let(:enabled_repos) { [instance_double(Agama::Software::Repository, name: "Repo #1")] } + + it "adds the proposal issues" do + subject.public_send(tested_method) + + expect(subject.issues).to include(an_object_having_attributes( + description: /proposal issue/i + )) + end + end + + context "if there is no enabled repository" do + let(:enabled_repos) { [] } + + it "does not add the proposal issues" do + subject.public_send(tested_method) + + expect(subject.issues).to_not include(an_object_having_attributes( + description: /proposal issue/i + )) + end + end + end + describe "#probe" do let(:rootdir) { Dir.mktmpdir } let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") } @@ -120,6 +200,8 @@ expect(repositories).to receive(:load) subject.probe end + + include_examples "software issues", "probe" end describe "#products" do @@ -164,6 +246,8 @@ .with("agama", :package, ["mandatory_pkg", "mandatory_pkg_s390"]) subject.propose end + + include_examples "software issues", "propose" end describe "#install" do @@ -193,43 +277,6 @@ end end - describe "#validate" do - before do - allow(repositories).to receive(:enabled).and_return(enabled_repos) - allow(repositories).to receive(:disabled).and_return(disabled_repos) - allow(proposal).to receive(:errors).and_return([proposal_error]) - end - - let(:enabled_repos) { [] } - let(:disabled_repos) { [] } - let(:proposal_error) { Agama::ValidationError.new("proposal error") } - - context "when there are not enabled repositories" do - it "does not return the proposal errors" do - expect(subject.validate).to_not include(proposal_error) - end - end - - context "when there are disabled repositories" do - let(:disabled_repos) do - [instance_double(Agama::Software::Repository, name: "Repo #1")] - end - - it "returns an error for each disabled repository" do - expect(subject.validate.size).to eq(1) - error = subject.validate.first - expect(error.message).to match(/Could not read the repository/) - end - end - - context "when there are enabled repositories" do - let(:enabled_repos) { [instance_double(Agama::Software::Repository)] } - it "returns the proposal errors" do - expect(subject.validate).to include(proposal_error) - end - end - end - describe "#finish" do let(:rootdir) { Dir.mktmpdir } let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") } @@ -284,5 +331,6 @@ end end + include_examples "issues" include_examples "progress" end From f16b07eaa0782387ad310fe89dbf692d9f326923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 9 Oct 2023 17:03:43 +0100 Subject: [PATCH 12/97] [service] Small fixes --- service/lib/agama/software/manager.rb | 1 - service/lib/agama/with_issues.rb | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 00d974fafc..e9255b735c 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -80,7 +80,6 @@ def initialize(config, logger) # patterns selected by user @user_patterns = [] @selected_patterns_change_callbacks = [] - update_issues end def select_product(name) diff --git a/service/lib/agama/with_issues.rb b/service/lib/agama/with_issues.rb index d682200760..dd45a4cf68 100644 --- a/service/lib/agama/with_issues.rb +++ b/service/lib/agama/with_issues.rb @@ -29,12 +29,11 @@ def issues @issues || [] end - def errors - issues.select(&:error?) - end - + # Whether there are errors + # + # @return [Boolean] def errors? - errors.any? + issues.any?(&:error?) end # Sets the list of current issues From 94dd8964f6f2d733de841e65e4b9cb324bf8b8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 9 Oct 2023 17:04:08 +0100 Subject: [PATCH 13/97] [service] Improve generation of connect result --- service/lib/agama/dbus/software/manager.rb | 49 +++++++++++++++++----- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index a9fc91dd4c..8b6da5c76f 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -240,26 +240,55 @@ def compute_patterns patterns end - # @return [Array] + # Result from calling to SUSE connect. + # + # @raise [Exception] if an unexpected error is found. + # + # @return [Array] List including a result code and a description + # (e.g., [1, "Connection to registration server failed (network error)"]). + # + # Possible result codes: + # 0: success + # 1: network error + # 2: timeout error + # 3: api error + # 4: missing credentials + # 5: incorrect credentials + # 6: invalid certificate + # 7: internal error (e.g., parsing json data) def connect_result(&block) block.call [0, ""] rescue SocketError => e - logger.error("Network error: #{e}") - [1, "Connection to registration server failed (network error)"] + connect_result_from_error(e, 1, "network error") rescue Timeout::Error => e - logger.error("Timeout error: #{e}") - [2, "Connection to registration server failed (timeout)"] + connect_result_from_error(e, 2, "timeout") rescue SUSE::Connect::ApiError => e - [3, "Connection to registration server failed"] + connect_result_from_error(e, 3) rescue SUSE::Connect::MissingSccCredentialsFile => e - [4, "Connection to registration server failed (missing credentials)"] + connect_result_from_error(e, 4, "missing credentials") rescue SUSE::Connect::MalformedSccCredentialsFile => e - [5, "Connection to registration server failed (incorrect credentials)"] + connect_result_from_error(e, 5, "incorrect credentials") rescue OpenSSL::SSL::SSLError => e - [6, "Connection to registration server failed (invalid certificate)"] + connect_result_from_error(e, 6, "invalid certificate") rescue JSON::ParserError => e - [7, "Connection to registration server failed"] + connect_result_from_error(e, 7) + end + + # Generates a result the from the given error. + # + # @param error [Exception] + # @param code [Integer] + # @param details [String, nil] + # + # @return [Array] List including a result code and a description. + def connect_result_from_error(error, code, details = nil) + logger.error("Error connecting to registration server: #{error}") + + description = "Connection to registration server failed" + description += " (#{details})" if details + + [code, description] end end end From b0734a576009cb706bacb728a760116e821186ea Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 9 Oct 2023 20:56:58 +0200 Subject: [PATCH 14/97] fix missing require --- service/lib/agama/software/manager.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index e9255b735c..a2ed85ab02 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -27,6 +27,7 @@ require "agama/config" require "agama/helpers" require "agama/issue" +require "agama/registration" require "agama/software/callbacks" require "agama/software/proposal" require "agama/software/repositories_manager" From 551a643e20d7f2f2d637db4ffa635751b3919878 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 9 Oct 2023 21:51:02 +0200 Subject: [PATCH 15/97] make rubocop happy --- service/lib/agama/registration.rb | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 4d9783f996..da0f60611d 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Copyright (c) [2024] SUSE LLC # # All Rights Reserved. @@ -36,7 +38,7 @@ def initialize(software_manager) end def register(code, email: "") - target_distro = "ALP-Dolomite-1-x86_64" # TODO read it + target_distro = "ALP-Dolomite-1-x86_64" # TODO: read it connect_params = { token: code, email: email @@ -50,9 +52,9 @@ def register(code, email: "") registration.register(email, code, target_distro) # TODO: fill it properly for scc target_product = OpenStruct.new( - arch: "x86_64", - identifier: "ALP-Dolomite", - version: "1.0", + arch: "x86_64", + identifier: "ALP-Dolomite", + version: "1.0", release_type: "ALPHA" ) activate_params = {} @@ -63,14 +65,11 @@ def register(code, email: "") @email = email end - def deregister - end + def deregister; end - def disabled? - end + def disabled?; end - def optional? - end + def optional?; end # callback when state changed like when different product is selected def on_state_change(&block) From 409801123d3d565d36de78100de5a03abc30e577 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 9 Oct 2023 22:01:10 +0200 Subject: [PATCH 16/97] fix typo --- service/lib/agama/registration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index da0f60611d..d1dcd8e80b 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -44,7 +44,7 @@ def register(code, email: "") email: email } - login, password = SUSE::Connect::YaST.announce_system(connect_params, distro_target) + login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro) # write the global credentials # TODO: check if we can do it in memory for libzypp SUSE::Connect::YaST.create_credentials_file(login, password) From 834a7dc7e5fd6fba7f4836901b19c81bb317db6c Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 9 Oct 2023 22:12:48 +0200 Subject: [PATCH 17/97] drop removed method from y2-registration --- service/lib/agama/registration.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index d1dcd8e80b..54e1c8a6c1 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -49,7 +49,6 @@ def register(code, email: "") # TODO: check if we can do it in memory for libzypp SUSE::Connect::YaST.create_credentials_file(login, password) - registration.register(email, code, target_distro) # TODO: fill it properly for scc target_product = OpenStruct.new( arch: "x86_64", From d875395a566bc5c80ef7fe8806c6530ee072837e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 10:57:03 +0200 Subject: [PATCH 18/97] try registation without release type --- service/lib/agama/registration.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 54e1c8a6c1..fe209d715a 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -53,8 +53,7 @@ def register(code, email: "") target_product = OpenStruct.new( arch: "x86_64", identifier: "ALP-Dolomite", - version: "1.0", - release_type: "ALPHA" + version: "1.0" ) activate_params = {} service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) From 861c20290ad96adc46dcba9bba0a8779b709b7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 10 Oct 2023 10:23:32 +0100 Subject: [PATCH 19/97] [service] Small improvements --- service/lib/agama/dbus/software/manager.rb | 12 ++++++------ service/lib/agama/software/proposal.rb | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 8b6da5c76f..2be008be69 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -244,7 +244,7 @@ def compute_patterns # # @raise [Exception] if an unexpected error is found. # - # @return [Array] List including a result code and a description + # @return [Array(Integer, String)] List including a result code and a description # (e.g., [1, "Connection to registration server failed (network error)"]). # # Possible result codes: @@ -275,20 +275,20 @@ def connect_result(&block) connect_result_from_error(e, 7) end - # Generates a result the from the given error. + # Generates a result from a given error. # # @param error [Exception] - # @param code [Integer] + # @param result_code [Integer] # @param details [String, nil] # - # @return [Array] List including a result code and a description. - def connect_result_from_error(error, code, details = nil) + # @return [Array(Integer, String)] List including a result code and a description. + def connect_result_from_error(error, result_code, details = nil) logger.error("Error connecting to registration server: #{error}") description = "Connection to registration server failed" description += " (#{details})" if details - [code, description] + [result_code, description] end end end diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index 45470fc593..383e1509d5 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -111,7 +111,7 @@ def packages_size # # @return [Boolean] def valid? - proposal && !errors? + !(proposal.nil? || errors?) end private From 719d4214e2308c555d4bb9e7dfd3fe14bf3d32ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 10 Oct 2023 10:23:49 +0100 Subject: [PATCH 20/97] [service] Fix tests --- service/test/agama/software/proposal_test.rb | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb index 3c850b466f..8dfdf1cb94 100644 --- a/service/test/agama/software/proposal_test.rb +++ b/service/test/agama/software/proposal_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -77,9 +77,9 @@ end context "when no errors were reported" do - it "does not register any error" do + it "does not register any issue" do subject.calculate - expect(subject.errors).to be_empty + expect(subject.issues).to be_empty end end @@ -88,10 +88,10 @@ { "warning_level" => :blocker, "warning" => "Could not install..." } end - it "registers the corresponding validation error" do + it "registers the corresponding issue" do subject.calculate - expect(subject.errors).to eq( - [Agama::ValidationError.new("Could not install...")] + expect(subject.issues).to contain_exactly( + an_object_having_attributes({ description: "Could not install..." }) ) end end @@ -100,13 +100,11 @@ let(:last_error) { "Solving errors..." } let(:solve_errors) { 5 } - it "registers them as validation errors" do + it "registers them as issues" do subject.calculate - expect(subject.errors).to eq( - [ - Agama::ValidationError.new("Solving errors..."), - Agama::ValidationError.new("Found 5 dependency issues.") - ] + expect(subject.issues).to contain_exactly( + an_object_having_attributes(description: "Solving errors..."), + an_object_having_attributes(description: "Found 5 dependency issues.") ) end end From 9881d893f5a772352a6469c710c67feaebf7eaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 10 Oct 2023 13:39:45 +0100 Subject: [PATCH 21/97] [service] Continue working on the D-Bus API --- service/lib/agama/dbus/software/manager.rb | 117 +++++-- service/lib/agama/registration.rb | 37 ++- .../test/agama/dbus/software/manager_test.rb | 292 ++++++++++++++++++ 3 files changed, 404 insertions(+), 42 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 2be008be69..4219607ce1 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -28,6 +28,7 @@ require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" require "agama/dbus/with_service_status" +require "agama/registration" module Agama module DBus @@ -171,10 +172,10 @@ def finish dbus_reader(:email, "s") - dbus_reader(:state, "u") + dbus_reader(:requirement, "u") dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args| - [register(*args)] + [register(args[0], email: args[1]["Email"])] end dbus_method(:Deregister, "out result:(us)") { [deregister] } @@ -188,20 +189,75 @@ def email backend.registration.email || "" end - def state - # TODO - 0 + # Registration requirement. + # + # @return [Integer] Possible values: + # 0: not required + # 1: optional + # 2: mandatory + def requirement + case backend.registration.requirement + when Agama::Registration::Requirement::MANDATORY + 2 + when Agama::Registration::Requirement::OPTIONAL + 1 + else + 0 + end end - def register(reg_code, options) - connect_result do - backend.registration.register(reg_code, email: options["Email"]) + # Tries to register with the given registration code. + # + # @param reg_code [String] + # @param email [String, nil] + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: already registered + # 3: network error + # 4: timeout error + # 5: api error + # 6: missing credentials + # 7: incorrect credentials + # 8: invalid certificate + # 9: internal error (e.g., parsing json data) + def register(reg_code, email: nil) + if !backend.product + [1, "Product not selected yet"] + elsif backend.registration.reg_code + [2, "Product already registered"] + else + connect_result(first_error_code: 3) do + backend.registration.register(reg_code, email: email) + end end end + # Tries to deregister. + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: not registered yet + # 3: network error + # 4: timeout error + # 5: api error + # 6: missing credentials + # 7: incorrect credentials + # 8: invalid certificate + # 9: internal error (e.g., parsing json data) def deregister - connect_result do - backend.registration.deregister + if !backend.product + [1, "Product not selected yet"] + elsif !backend.registration.reg_code + [2, "Product not registered yet"] + else + connect_result(first_error_code: 3) do + backend.registration.deregister + end end end @@ -226,6 +282,8 @@ def register_callbacks self.selected_patterns = compute_patterns end + backend.registration.on_change { registration_properties_changed } + backend.on_issues_change { issues_properties_changed } end @@ -240,55 +298,50 @@ def compute_patterns patterns end + def registration_properties_changed + dbus_properties_changed(REGISTRATION_INTERFACE, + interfaces_and_properties[REGISTRATION_INTERFACE], []) + end + # Result from calling to SUSE connect. # # @raise [Exception] if an unexpected error is found. # # @return [Array(Integer, String)] List including a result code and a description # (e.g., [1, "Connection to registration server failed (network error)"]). - # - # Possible result codes: - # 0: success - # 1: network error - # 2: timeout error - # 3: api error - # 4: missing credentials - # 5: incorrect credentials - # 6: invalid certificate - # 7: internal error (e.g., parsing json data) - def connect_result(&block) + def connect_result(first_error_code: 1, &block) block.call [0, ""] rescue SocketError => e - connect_result_from_error(e, 1, "network error") + connect_result_from_error(e, first_error_code, "network error") rescue Timeout::Error => e - connect_result_from_error(e, 2, "timeout") + connect_result_from_error(e, first_error_code + 1, "timeout") rescue SUSE::Connect::ApiError => e - connect_result_from_error(e, 3) + connect_result_from_error(e, first_error_code + 2) rescue SUSE::Connect::MissingSccCredentialsFile => e - connect_result_from_error(e, 4, "missing credentials") + connect_result_from_error(e, first_error_code + 3, "missing credentials") rescue SUSE::Connect::MalformedSccCredentialsFile => e - connect_result_from_error(e, 5, "incorrect credentials") + connect_result_from_error(e, first_error_code + 4, "incorrect credentials") rescue OpenSSL::SSL::SSLError => e - connect_result_from_error(e, 6, "invalid certificate") + connect_result_from_error(e, first_error_code + 5, "invalid certificate") rescue JSON::ParserError => e - connect_result_from_error(e, 7) + connect_result_from_error(e, first_error_code + 6) end # Generates a result from a given error. # # @param error [Exception] - # @param result_code [Integer] + # @param error_code [Integer] # @param details [String, nil] # - # @return [Array(Integer, String)] List including a result code and a description. - def connect_result_from_error(error, result_code, details = nil) + # @return [Array(Integer, String)] List including an error code and a description. + def connect_result_from_error(error, error_code, details = nil) logger.error("Error connecting to registration server: #{error}") description = "Connection to registration server failed" description += " (#{details})" if details - [result_code, description] + [error_code, description] end end end diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index fe209d715a..afe0356e02 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -31,10 +31,15 @@ class Registration attr_reader :reg_code attr_reader :email + module Requirement + NOT_REQUIRED = :not_required + OPTIONAL = :optional + MANDATORY = :mandatory + end + # initializes registration with instance of software manager for query about products def initialize(software_manager) @software = software_manager - @on_state_change_callbacks = [] end def register(code, email: "") @@ -51,9 +56,9 @@ def register(code, email: "") # TODO: fill it properly for scc target_product = OpenStruct.new( - arch: "x86_64", - identifier: "ALP-Dolomite", - version: "1.0" + arch: "x86_64", + identifier: "ALP-Dolomite", + version: "1.0" ) activate_params = {} service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) @@ -61,17 +66,29 @@ def register(code, email: "") @reg_code = code @email = email + run_on_change_callbacks end - def deregister; end - - def disabled?; end + # TODO + def deregister + # run_on_change_callbacks + end - def optional?; end + # TODO: check whether the selected product requires registration + def requirement + Requirement::NOT_REQUIRED + end # callback when state changed like when different product is selected - def on_state_change(&block) - @on_state_change_callbacks << block + def on_change(&block) + @on_change_callbacks ||= [] + @on_change_callbacks << block + end + + private + + def run_on_change_callbacks + @on_change_callbacks&.map(&:call) end end end diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index edc0ae1946..9749fccec1 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -136,4 +136,296 @@ expect(installed).to eq(true) end end + + describe "#reg_code" do + before do + allow(backend.registration).to receive(:reg_code).and_return(reg_code) + end + + context "if there is no registered product yet" do + let(:reg_code) { nil } + + it "returns an empty string" do + expect(subject.reg_code).to eq("") + end + end + + context "if there is a registered product" do + let(:reg_code) { "123XX432" } + + it "returns the registration code" do + expect(subject.reg_code).to eq("123XX432") + end + end + end + + describe "#email" do + before do + allow(backend.registration).to receive(:email).and_return(email) + end + + context "if there is no registered email" do + let(:email) { nil } + + it "returns an empty string" do + expect(subject.email).to eq("") + end + end + + context "if there is a registered email" do + let(:email) { "test@suse.com" } + + it "returns the registered email" do + expect(subject.email).to eq("test@suse.com") + end + end + end + + describe "#requirement" do + before do + allow(backend.registration).to receive(:requirement).and_return(requirement) + end + + context "if the registration is not required" do + let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED } + + it "returns 0" do + expect(subject.requirement).to eq(0) + end + end + + context "if the registration is optional" do + let(:requirement) { Agama::Registration::Requirement::OPTIONAL } + + it "returns 1" do + expect(subject.requirement).to eq(1) + end + end + + context "if the registration is mandatory" do + let(:requirement) { Agama::Registration::Requirement::MANDATORY } + + it "returns 2" do + expect(subject.requirement).to eq(2) + end + end + end + + describe "#register" do + before do + allow(backend).to receive(:product).and_return("Tumbleweed") + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + context "if there is no product selected yet" do + before do + allow(backend).to receive(:product).and_return(nil) + end + + it "returns result code 1 and description" do + expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i) + end + end + + context "if the product is already registered" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) + end + end + + context "if the registration is correctly done" do + before do + allow(backend.registration).to receive(:register) + end + + it "returns result code 0 without description" do + expect(subject.register("123XX432")).to contain_exactly(0, "") + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:register).and_raise(SocketError) + end + + it "returns result code 3 and description" do + expect(subject.register("123XX432")).to contain_exactly(3, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:register).and_raise(Timeout::Error) + end + + it "returns result code 4 and description" do + expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 5 and description" do + expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 6 and description" do + expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 8 and description" do + expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) + end + + it "returns result code 9 and description" do + expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) + end + end + end + + describe "#deregister" do + before do + allow(backend).to receive(:product).and_return("Tumbleweed") + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + context "if there is no product selected yet" do + before do + allow(backend).to receive(:product).and_return(nil) + end + + it "returns result code 1 and description" do + expect(subject.deregister).to contain_exactly(1, /product not selected/i) + end + end + + context "if the product is not registered yet" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + it "returns result code 2 and description" do + expect(subject.deregister).to contain_exactly(2, /product not registered/i) + end + end + + context "if the deregistration is correctly done" do + before do + allow(backend.registration).to receive(:deregister) + end + + it "returns result code 0 without description" do + expect(subject.deregister).to contain_exactly(0, "") + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SocketError) + end + + it "returns result code 3 and description" do + expect(subject.deregister).to contain_exactly(3, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) + end + + it "returns result code 4 and description" do + expect(subject.deregister).to contain_exactly(4, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 5 and description" do + expect(subject.deregister).to contain_exactly(5, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 6 and description" do + expect(subject.deregister).to contain_exactly(6, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 8 and description" do + expect(subject.deregister).to contain_exactly(8, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) + end + + it "returns result code 9 and description" do + expect(subject.deregister).to contain_exactly(9, /registration server failed/) + end + end + end end From 4f90278fcfa79ad3631d583f104bb8d285c2ee4e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 15:28:38 +0200 Subject: [PATCH 22/97] support deregister --- service/lib/agama/registration.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index afe0356e02..e6274b9800 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -69,9 +69,22 @@ def register(code, email: "") run_on_change_callbacks end - # TODO def deregister - # run_on_change_callbacks + connect_params = { + email: email + } + SUSE::Connect::YaST.deactivate_system(connect_params) + + # TODO: fill it properly for scc + target_product = OpenStruct.new( + arch: "x86_64", + identifier: "ALP-Dolomite", + version: "1.0" + ) + deactivate_params = {} + service = SUSE::Connect::YaST.activate_product(target_product, deactivate_params) + Y2Packager::NewRepositorySetup.instance.services.delete(service.name) + run_on_change_callbacks end # TODO: check whether the selected product requires registration From fe89d84ce807f9dc4a0f0dcc74ef3ca4eec06e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 10 Oct 2023 14:53:03 +0100 Subject: [PATCH 23/97] [service] Add registration issue --- service/lib/agama/software/manager.rb | 22 +++++++++-- service/test/agama/software/manager_test.rb | 42 +++++++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index a2ed85ab02..21f51656e8 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -359,10 +359,7 @@ def current_issues # packages. Those issues does not make any sense if there are no repositories to install # from. issues += proposal.issues if repositories.enabled.any? - - # TODO - # issues += registration.issues - + issues << missing_registration_issue if missing_registration? issues end @@ -387,6 +384,23 @@ def repos_issues severity: Issue::Severity::ERROR) end end + + # Issue when a product requires registration but it is not registered yet. + # + # @return [Agama::Issue] + def missing_registration_issue + Issue.new("Product must be registered", + source: Issue::Source::SYSTEM, + severity: Issue::Severity::ERROR) + end + + # Whether the registration is missing. + # + # @return [Boolean] + def missing_registration? + registration.reg_code.nil? && + registration.requirement == Agama::Registration::Requirement::MANDATORY + end end end end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index befa2c1787..4095f91775 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -27,6 +27,7 @@ ) require "agama/config" require "agama/issue" +require "agama/registration" require "agama/software/manager" require "agama/software/proposal" require "agama/dbus/clients/questions" @@ -95,15 +96,16 @@ shared_examples "software issues" do |tested_method| before do - allow(subject).to receive(:product).and_return("Tumbleweed") + allow(subject).to receive(:product).and_return(product) + allow(subject.registration).to receive(:reg_code).and_return(reg_code) end + let(:product) { "ALP-Dolomite" } + let(:reg_code) { "123XX432" } let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] } context "if there is no product selected yet" do - before do - allow(subject).to receive(:product).and_return(nil) - end + let(:product) { nil } it "sets an issue" do subject.public_send(tested_method) @@ -159,6 +161,38 @@ )) end end + + context "if the product is not registered" do + let(:reg_code) { nil } + + before do + allow(subject.registration).to receive(:requirement).and_return(reg_requirement) + end + + context "and registration is mandatory" do + let(:reg_requirement) { Agama::Registration::Requirement::MANDATORY } + + it "adds registration issue" do + subject.public_send(tested_method) + + expect(subject.issues).to include(an_object_having_attributes( + description: /product must be registered/i + )) + end + end + + context "and registration is not mandatory" do + let(:reg_requirement) { Agama::Registration::Requirement::OPTIONAL } + + it "does not add registration issue" do + subject.public_send(tested_method) + + expect(subject.issues).to_not include(an_object_having_attributes( + description: /product must be registered/i + )) + end + end + end end describe "#probe" do From 7ccfa41acec7155f1bbefdf065c20c5da6440b80 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 16:14:45 +0200 Subject: [PATCH 24/97] add to deregister also code --- service/lib/agama/registration.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index e6274b9800..f9234aaf4f 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -50,6 +50,7 @@ def register(code, email: "") } login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro) + @system_code = code # remember code to be able to deregister # write the global credentials # TODO: check if we can do it in memory for libzypp SUSE::Connect::YaST.create_credentials_file(login, password) @@ -71,6 +72,7 @@ def register(code, email: "") def deregister connect_params = { + token: @system_code, email: email } SUSE::Connect::YaST.deactivate_system(connect_params) From 60ab7baea34ce1570f98b03c4b864e29e6c52924 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 16:38:29 +0200 Subject: [PATCH 25/97] fix typo and order --- service/lib/agama/registration.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index f9234aaf4f..252c574b35 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -71,12 +71,6 @@ def register(code, email: "") end def deregister - connect_params = { - token: @system_code, - email: email - } - SUSE::Connect::YaST.deactivate_system(connect_params) - # TODO: fill it properly for scc target_product = OpenStruct.new( arch: "x86_64", @@ -84,8 +78,15 @@ def deregister version: "1.0" ) deactivate_params = {} - service = SUSE::Connect::YaST.activate_product(target_product, deactivate_params) + service = SUSE::Connect::YaST.deactivate_product(target_product, deactivate_params) Y2Packager::NewRepositorySetup.instance.services.delete(service.name) + + connect_params = { + token: @system_code, + email: email + } + SUSE::Connect::YaST.deactivate_system(connect_params) + run_on_change_callbacks end From 18f5de9dadaae7295ee57b8561c5f949fca11923 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 17:05:22 +0200 Subject: [PATCH 26/97] fix base product cannot be deactivated --- service/lib/agama/registration.rb | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 252c574b35..2186bbfa91 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -62,8 +62,8 @@ def register(code, email: "") version: "1.0" ) activate_params = {} - service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) - Y2Packager::NewRepositorySetup.instance.add_service(service.name) + @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) + Y2Packager::NewRepositorySetup.instance.add_service(@service.name) @reg_code = code @email = email @@ -71,15 +71,7 @@ def register(code, email: "") end def deregister - # TODO: fill it properly for scc - target_product = OpenStruct.new( - arch: "x86_64", - identifier: "ALP-Dolomite", - version: "1.0" - ) - deactivate_params = {} - service = SUSE::Connect::YaST.deactivate_product(target_product, deactivate_params) - Y2Packager::NewRepositorySetup.instance.services.delete(service.name) + Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) connect_params = { token: @system_code, From a4cc72ba6536e7fa1b44aca780b1e252609360d0 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 17:35:18 +0200 Subject: [PATCH 27/97] remove credentials file --- service/lib/agama/registration.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 2186bbfa91..da0c847fdd 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -19,6 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "fileutils" require "yast" require "ostruct" require "suse/connect" @@ -78,6 +79,7 @@ def deregister email: email } SUSE::Connect::YaST.deactivate_system(connect_params) + FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself run_on_change_callbacks end From 895a092e4085aa5b0fc936f98b75b830c0dfdf37 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 10 Oct 2023 17:52:05 +0200 Subject: [PATCH 28/97] properly uninitialize internal variables --- service/lib/agama/registration.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index da0c847fdd..7ae513ee8f 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -81,6 +81,9 @@ def deregister SUSE::Connect::YaST.deactivate_system(connect_params) FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself + # reset varibles here + @reg_code = nil + @email = nil run_on_change_callbacks end From cd6a22b520ee1abf6ffd3b7b4f67d2980e816637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Oct 2023 16:45:30 +0100 Subject: [PATCH 29/97] [service] Add Product and ProductBuilder --- service/lib/agama/config.rb | 27 +++++-- service/lib/agama/software/product.rb | 58 ++++++++++++++ service/lib/agama/software/product_builder.rb | 78 +++++++++++++++++++ 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 service/lib/agama/software/product.rb create mode 100644 service/lib/agama/software/product_builder.rb diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 0a038abadf..8f528d550a 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -92,10 +92,7 @@ def products # cannot use `data` here to avoid endless loop as in data we use # pick_product that select product from products - @products = @pure_data["products"].select do |_key, value| - value["archs"].nil? || - Yast2::ArchFilter.from_string(value["archs"]).match? - end + @products = @pure_data["products"].select { |_k, v| arch_match?(v["archs"]) } end # Whether there are more than one product @@ -120,6 +117,20 @@ def merge(config) Config.new(simple_merge(data, config.data)) end + def arch_elements_from(*keys, property: nil) + keys.map!(&:to_s) + elements = pure_data.dig(*keys) + return [] unless elements + + elements.map do |element| + if !element.is_a?(Hash) + element + elsif arch_match?(element["archs"]) + property ? element[property.to_s] : element + end + end.compact + end + private # Simple deep merge @@ -138,5 +149,11 @@ def simple_merge(a_hash, another_hash) end end end + + def arch_match?(archs) + return true if archs.nil? + + Yast2::ArchFilter.from_string(archs).match? + end end end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb new file mode 100644 index 0000000000..244304bbed --- /dev/null +++ b/service/lib/agama/software/product.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/software/repositories_manager" + +module Agama + module Software + # Represents a product that Agama can install. + class Product + attr_reader :id + + attr_accessor :display_name + + attr_accessor :description + + attr_accessor :name + + attr_accessor :version + + attr_accessor :repositories + + attr_accessor :mandatory_packages + + attr_accessor :optional_packages + + attr_accessor :mandatory_patterns + + attr_accessor :optional_patterns + + def initialize(id) + @id = id + @repositories = [] + @mandatory_packages = [] + @optional_packages = [] + @mandatory_patterns = [] + @optional_patterns = [] + end + end + end +end diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb new file mode 100644 index 0000000000..13a709b735 --- /dev/null +++ b/service/lib/agama/software/product_builder.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/software/product" + +module Agama + module Software + # Builds products from the information of a config file. + class ProductBuilder + def initialize(config) + @config = config + end + + def build + config.products.map do |id, attrs| + data = product_data_from_config(id) + + Agama::Software::Product.new(id).tap do |product| + product.display_name = attrs["name"] + product.description = attrs["description"] + product.name = data[:name] + product.version = data[:version] + product.repositories = data[:repositories] + product.mandatory_packages = data[:mandatory_packages] + product.optional_packages = data[:optional_packages] + product.mandatory_patterns = data[:mandatory_patterns] + product.optional_patterns = data[:optional_patterns] + end + end + end + + private + + # @return [Agama::Config] + attr_reader :config + + def product_data_from_config(id) + { + name: config.pure_data.dig(id, "software", "base_product"), + version: config.pure_data.dig(id, "software", "version"), + repositories: config.arch_elements_from( + id, "software", "installation_repositories", property: :url + ), + mandatory_packages: config.arch_elements_from( + id, "software", "mandatory_packages", property: :package + ), + optional_packages: config.arch_elements_from( + id, "software", "optional_packages", property: :package + ), + mandatory_patterns: config.arch_elements_from( + id, "software", "mandatory_patterns", property: :pattern + ), + optional_patterns: config.arch_elements_from( + id, "software", "optional_patterns", property: :pattern + ) + } + end + end + end +end From b50ac90f9bdf0682c46d763a70dc059730516391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Oct 2023 16:46:32 +0100 Subject: [PATCH 30/97] [service] Adapt manager to use products --- service/lib/agama/dbus/software/manager.rb | 8 +- service/lib/agama/software/manager.rb | 100 +++++++++------------ 2 files changed, 48 insertions(+), 60 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 4219607ce1..cea6f9e67e 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -72,7 +72,7 @@ def issues dbus_reader :selected_product, "s" dbus_method :SelectProduct, "in ProductID:s" do |product_id| - old_product_id = backend.product + old_product_id = backend.product&.id if old_product_id == product_id logger.info "Do not changing the product as it is still the same (#{product_id})" @@ -130,8 +130,8 @@ def issues end def available_products - backend.products.map do |id, data| - [id, data["name"], { "description" => data["description"] }].freeze + backend.products.map do |product| + [product.id, product.display_name, { "description" => product.description }] end end @@ -139,7 +139,7 @@ def available_products # # @return [String] Product ID or an empty string if no product is selected def selected_product - backend.product || "" + backend.product&.id || "" end def select_product(product_id) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 21f51656e8..a424c12f49 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -21,7 +21,6 @@ require "fileutils" require "yast" -require "yast2/arch_filter" require "y2packager/product" require "y2packager/resolvable" require "agama/config" @@ -29,6 +28,8 @@ require "agama/issue" require "agama/registration" require "agama/software/callbacks" +require "agama/software/product" +require "agama/software/product_builder" require "agama/software/proposal" require "agama/software/repositories_manager" require "agama/with_progress" @@ -51,6 +52,9 @@ class Manager GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" private_constant :GPG_KEYS_GLOB + # Selected product + # + # @return [Agama::Product, nil] attr_reader :product DEFAULT_LANGUAGES = ["en_US"].freeze @@ -58,42 +62,44 @@ class Manager attr_accessor :languages - # FIXME: what about defining a Product class? - # @return [Array>] An array containing the product ID and - # additional information in a hash + # Available products for installation. + # + # @return [Array] attr_reader :products + # @return [Agama::RepositoriesManager] attr_reader :repositories def initialize(config, logger) @config = config @logger = logger @languages = DEFAULT_LANGUAGES - @products = @config.products - if @config.multi_product? - @product = nil - else - @product = @products.keys.first # use the available product as default - @config.pick_product(@product) - end + @products = build_products + @product = @products.first if @products.size == 1 @repositories = RepositoriesManager.new - on_progress_change { logger.info progress.to_s } # patterns selected by user @user_patterns = [] @selected_patterns_change_callbacks = [] + + on_progress_change { logger.info(progress.to_s) } end - def select_product(name) - return if name == @product - raise ArgumentError unless @products[name] + def select_product(id) + return if id == product&.id + + new_product = @products.find { |p| p.id == id } - @config.pick_product(name) - @product = name + raise ArgumentError unless new_product + + @product = new_product repositories.delete_all update_issues end def probe + # Should an error be raised? + return unless product + logger.info "Probing software" # as we use liveDVD with normal like ENV, lets temporary switch to normal to use its repos @@ -123,7 +129,10 @@ def initialize_target_repos # Updates the software proposal def propose - proposal.base_product = selected_base_product + # Should an error be raised? + return unless product + + proposal.base_product = product.name proposal.languages = languages select_resolvables result = proposal.calculate @@ -258,13 +267,20 @@ def registration private - def proposal - @proposal ||= Proposal.new - end + # @return [Agama::Config] + attr_reader :config # @return [Logger] attr_reader :logger + def build_products + ProductBuilder.new(config).build + end + + def proposal + @proposal ||= Proposal.new + end + def import_gpg_keys gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s) logger.info "Importing GPG keys: #{gpg_keys}" @@ -273,27 +289,8 @@ def import_gpg_keys end end - def arch_select(section) - collection = @config.data["software"][section] || [] - collection.select { |c| !c.is_a?(Hash) || arch_match?(c["archs"]) } - end - - def arch_collection_for(section, key) - arch_select(section).map { |r| r.is_a?(Hash) ? r[key] : r } - end - - def selected_base_product - @config.data["software"]["base_product"] - end - - def arch_match?(archs) - return true if archs.nil? - - Yast2::ArchFilter.from_string(archs).match? - end - def add_base_repos - arch_collection_for("installation_repositories", "url").map { |url| repositories.add(url) } + product.repositories.each { |url| repositories.add(url) } end REPOS_BACKUP = "/etc/zypp/repos.d.agama.backup" @@ -321,21 +318,12 @@ def restore_original_repos FileUtils.mv(REPOS_BACKUP, REPOS_DIR) end - # adds resolvables from yaml config for given product + # Adds resolvables for selected product def select_resolvables - mandatory_patterns = arch_collection_for("mandatory_patterns", "pattern") - proposal.set_resolvables("agama", :pattern, mandatory_patterns) - - optional_patterns = arch_collection_for("optional_patterns", "pattern") - proposal.set_resolvables("agama", :pattern, optional_patterns, - optional: true) - - mandatory_packages = arch_collection_for("mandatory_packages", "package") - proposal.set_resolvables("agama", :package, mandatory_packages) - - optional_packages = arch_collection_for("optional_packages", "package") - proposal.set_resolvables("agama", :package, optional_packages, - optional: true) + proposal.set_resolvables("agama", :pattern, product.mandatory_patterns) + proposal.set_resolvables("agama", :pattern, product.optional_patterns, optional: true) + proposal.set_resolvables("agama", :package, product.mandatory_packages) + proposal.set_resolvables("agama", :package, product.optional_packages, optional: true) end def selected_patterns_changed @@ -378,7 +366,7 @@ def missing_product_issue # # @return [Array] def repos_issues - issues = repositories.disabled.map do |repo| + repositories.disabled.map do |repo| Issue.new("Could not read the repository #{repo.name}", source: Issue::Source::SYSTEM, severity: Issue::Severity::ERROR) From 66c509af197902033c40b086605b688519c6b109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Oct 2023 16:47:57 +0100 Subject: [PATCH 31/97] [service] Register selected product instead of hardcoded --- service/lib/agama/registration.rb | 34 +++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 7ae513ee8f..a9763f39b2 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2023] SUSE LLC # # All Rights Reserved. # @@ -23,13 +23,15 @@ require "yast" require "ostruct" require "suse/connect" - require "y2packager/new_repository_setup" +Yast.import "Arch" + module Agama # Handles everything related to registration of system to SCC, RMT or similar class Registration attr_reader :reg_code + attr_reader :email module Requirement @@ -44,23 +46,23 @@ def initialize(software_manager) end def register(code, email: "") - target_distro = "ALP-Dolomite-1-x86_64" # TODO: read it + return unless product + connect_params = { token: code, email: email } login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro) - @system_code = code # remember code to be able to deregister # write the global credentials # TODO: check if we can do it in memory for libzypp SUSE::Connect::YaST.create_credentials_file(login, password) # TODO: fill it properly for scc target_product = OpenStruct.new( - arch: "x86_64", - identifier: "ALP-Dolomite", - version: "1.0" + arch: Yast::Arch.rpm_arch, + identifier: product.id, + version: product.version ) activate_params = {} @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) @@ -75,7 +77,7 @@ def deregister Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) connect_params = { - token: @system_code, + token: reg_code, email: email } SUSE::Connect::YaST.deactivate_system(connect_params) @@ -87,8 +89,10 @@ def deregister run_on_change_callbacks end - # TODO: check whether the selected product requires registration def requirement + return Requirement::NOT_REQUIRED unless product + return Requirement::MANDATORY if product.repositories.none? + Requirement::NOT_REQUIRED end @@ -100,6 +104,18 @@ def on_change(&block) private + attr_reader :software + + def product + software.product + end + + # E.g., "ALP-Dolomite-1-x86_64" + def target_distro + v = version.to_s.split(".").first || "1" + "#{product.id}-#{v}-#{Yast::Arch.rpm_arch}" + end + def run_on_change_callbacks @on_change_callbacks&.map(&:call) end From b36e988f316889b3534a83cca4ddb9e803ac32da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Oct 2023 16:48:27 +0100 Subject: [PATCH 32/97] [service] Add version to config file --- service/etc/agama.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/service/etc/agama.yaml b/service/etc/agama.yaml index 8192ebf72a..524d80676b 100644 --- a/service/etc/agama.yaml +++ b/service/etc/agama.yaml @@ -46,6 +46,7 @@ ALP-Dolomite: archs: ppc64 optional_packages: null base_product: ALP-Dolomite + version: "1.0" security: tpm_luks_open: true From 3632f6a46c68585691cbe9f7e64b5c7130baf4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 11 Oct 2023 17:06:36 +0100 Subject: [PATCH 33/97] [web] Fix pattern selector --- web/src/components/software/PatternSelector.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/components/software/PatternSelector.jsx b/web/src/components/software/PatternSelector.jsx index 905bb40cf9..524baa8fab 100644 --- a/web/src/components/software/PatternSelector.jsx +++ b/web/src/components/software/PatternSelector.jsx @@ -153,7 +153,7 @@ function PatternSelector() { const refresh = async () => { setSelected(await client.software.selectedPatterns()); setUsed(await client.software.getUsedSpace()); - setErrors(await client.software.getValidationErrors()); + setErrors(await client.software.getIssues()); }; refresh(); @@ -166,7 +166,7 @@ function PatternSelector() { const loadData = async () => { setSelected(await client.software.selectedPatterns()); setUsed(await client.software.getUsedSpace()); - setErrors(await client.software.getValidationErrors()); + setErrors(await client.software.getIssues()); setPatterns(await client.software.patterns(true)); }; @@ -205,10 +205,14 @@ function PatternSelector() { // if there is just a single error then the error is displayed directly instead of this summary const errorLabel = sprintf(_("%d errors"), errors.length); + // FIXME: ValidationErrors should be replaced by an equivalent component to show issues. + // Note that only the Users client uses the old Validation D-Bus interface. + const validationErrors = errors.map(e => ({ message: e.description })); + return ( <> - + Date: Thu, 12 Oct 2023 17:39:09 +0200 Subject: [PATCH 34/97] add pkg related stuff after registration --- service/lib/agama/registration.rb | 52 ++++++++++++++++++++++++++- service/lib/agama/software/manager.rb | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index a9763f39b2..0ef795a611 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -41,8 +41,9 @@ module Requirement end # initializes registration with instance of software manager for query about products - def initialize(software_manager) + def initialize(software_manager, logger) @software = software_manager + @logger = logger end def register(code, email: "") @@ -67,6 +68,7 @@ def register(code, email: "") activate_params = {} @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) Y2Packager::NewRepositorySetup.instance.add_service(@service.name) + add_service(@service) @reg_code = code @email = email @@ -75,6 +77,7 @@ def register(code, email: "") def deregister Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) + remove_service(@service) connect_params = { token: reg_code, @@ -119,5 +122,52 @@ def target_distro def run_on_change_callbacks @on_change_callbacks&.map(&:call) end + + # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 + # TODO: move it to software manager + # rubocop:disable Metrics/AbcSize + def add_service(service) + # save repositories before refreshing added services (otherwise + # pkg-bindings will treat them as removed by the service refresh and + # unload them) + if !Yast::Pkg.SourceSaveAll + # error message + @logger.error("Saving repository configuration failed.") + end + + @logger.info "Adding service #{service.name.inspect} (#{service.url})" + if !Yast::Pkg.ServiceAdd(service.name, service.url.to_s) + raise format("Adding service '%s' failed.", service.name) + end + + if !Yast::Pkg.ServiceSet(service.name, "autorefresh" => true) + # error message + raise format("Updating service '%s' failed.", service.name) + end + + # refresh works only for saved services + if !Yast::Pkg.ServiceSave(service_name) + # error message + raise format("Saving service '%s' failed.", service_name) + end + + # Force refreshing due timing issues (bnc#967828) + if !Yast::Pkg.ServiceForceRefresh(service_name) + # error message + raise format("Refreshing service '%s' failed.", service_name) + end + ensure + Pkg.SourceSaveAll + end + # rubocop:enable Metrics/AbcSize + + # TODO: move it to software manager + def remove_service(service) + if Yast::Pkg.ServiceDelete(service.name) && !Pkg.SourceSaveAll + raise format("Removing service '%s' failed.", service_name) + end + + true + end end end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 32b76ef87f..b3530fa01d 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -263,7 +263,7 @@ def used_disk_space end def registration - @registration ||= Registration.new(self) + @registration ||= Registration.new(self, @logger) end private From 61147b78a0065d5f65b9d33fc1d32b33e8976f79 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 12 Oct 2023 21:21:33 +0200 Subject: [PATCH 35/97] fix crash for unknown version --- service/lib/agama/registration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 0ef795a611..fb8d4c803f 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -115,7 +115,7 @@ def product # E.g., "ALP-Dolomite-1-x86_64" def target_distro - v = version.to_s.split(".").first || "1" + v = product.version.to_s.split(".").first || "1" "#{product.id}-#{v}-#{Yast::Arch.rpm_arch}" end From 72cbf5369da79b14ca349db44fdbcce353288dc1 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 12 Oct 2023 21:43:32 +0200 Subject: [PATCH 36/97] fix crash for wrong namespace --- service/lib/agama/registration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index fb8d4c803f..b0930618d9 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -157,7 +157,7 @@ def add_service(service) raise format("Refreshing service '%s' failed.", service_name) end ensure - Pkg.SourceSaveAll + Yast::Pkg.SourceSaveAll end # rubocop:enable Metrics/AbcSize From 92754795028848c26680739ca4c9773ede895859 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 12 Oct 2023 22:28:00 +0200 Subject: [PATCH 37/97] fix typos --- service/lib/agama/registration.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index b0930618d9..f2af710ed2 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -146,15 +146,15 @@ def add_service(service) end # refresh works only for saved services - if !Yast::Pkg.ServiceSave(service_name) + if !Yast::Pkg.ServiceSave(service.name) # error message - raise format("Saving service '%s' failed.", service_name) + raise format("Saving service '%s' failed.", service.name) end # Force refreshing due timing issues (bnc#967828) - if !Yast::Pkg.ServiceForceRefresh(service_name) + if !Yast::Pkg.ServiceForceRefresh(service.name) # error message - raise format("Refreshing service '%s' failed.", service_name) + raise format("Refreshing service '%s' failed.", service.name) end ensure Yast::Pkg.SourceSaveAll From bede9c318aa5551b4750b511c72aedf4e490aa9a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 13 Oct 2023 09:43:24 +0200 Subject: [PATCH 38/97] add service specific credentials file --- service/lib/agama/registration.rb | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index f2af710ed2..7c0fe03e8e 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -67,6 +67,11 @@ def register(code, email: "") ) activate_params = {} @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) + # if service require specific credentials file, store it + @credentials_file = credentials_from_url(@service.url) + if @credentials_file + SUSE::Connect::YaST.create_credentials_file(login, password, @credentials_file) + end Y2Packager::NewRepositorySetup.instance.add_service(@service.name) add_service(@service) @@ -85,8 +90,13 @@ def deregister } SUSE::Connect::YaST.deactivate_system(connect_params) FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself + if @credentials_file + path = File.join(SUSE::Connect::YaST::DEFAULT_CREDENTIALS_DIR, @credentials_file) + FileUtils.rm(path) + @credentials_file = nil + end - # reset varibles here + # reset variables here @reg_code = nil @email = nil run_on_change_callbacks @@ -169,5 +179,16 @@ def remove_service(service) true end + + # taken from https://github.com/yast/yast-registration/blob/master/src/lib/registration/url_helpers.rb#L109 + def credentials_from_url(url) + parsed_url = URI(url) + params = Hash[URI.decode_www_form(parsed_url.query)] + + params["credentials"] + rescue + # if something goes wrong try to continue like if there is no credentials param + nil + end end end From a7b856d8308caaef708e223fb9395a95939bdf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 16 Oct 2023 11:37:02 +0100 Subject: [PATCH 39/97] [service] Improve documentation --- service/lib/agama/config.rb | 30 ++++++++++++++++++ service/lib/agama/registration.rb | 24 +++++++++++--- service/lib/agama/software/manager.rb | 12 ++++++- service/lib/agama/software/product.rb | 31 +++++++++++++++++++ service/lib/agama/software/product_builder.rb | 8 +++++ 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 8f528d550a..8f447f2b5a 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -117,6 +117,32 @@ def merge(config) Config.new(simple_merge(data, config.data)) end + # Elements that match the current arch. + # + # @example + # config.pure_data = { + # ALP-Dolomite: { + # software: { + # installation_repositories: { + # - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/ + # archs: x86_64 + # - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/ + # archs: aarch64 + # - https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/ + # } + # } + # } + # } + # + # Yast::Arch.rpm_arch #=> "x86_64" + # config.arch_elements_from("ALP-Dolomite", "software", "installation_repositories", + # property: :url) #=> ["https://.../SUSE/Products/ALP-Dolomite/1.0/x86_64/product/", + # #=> "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/"] + # + # @param keys [Array] Config data keys of the collection. + # @param property [Symbol|String|nil] Property to retrieve of the elements. + # + # @return [Array] def arch_elements_from(*keys, property: nil) keys.map!(&:to_s) elements = pure_data.dig(*keys) @@ -150,6 +176,10 @@ def simple_merge(a_hash, another_hash) end end + # Whether the current arch matches any of the given archs. + # + # @param archs [Array] + # @return [Boolean] def arch_match?(archs) return true if archs.nil? diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 7c0fe03e8e..2dbfcbac1a 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -28,10 +28,16 @@ Yast.import "Arch" module Agama - # Handles everything related to registration of system to SCC, RMT or similar + # Handles everything related to registration of system to SCC, RMT or similar. class Registration + # Code used for registering the product. + # + # @return [String, nil] nil if the product is not registered yet. attr_reader :reg_code + # Email used for registering the product. + # + # @return [String, nil] attr_reader :email module Requirement @@ -40,7 +46,8 @@ module Requirement MANDATORY = :mandatory end - # initializes registration with instance of software manager for query about products + # @param software_manager [Agama::Software::Manager] + # @param logger [Logger] def initialize(software_manager, logger) @software = software_manager @logger = logger @@ -102,6 +109,9 @@ def deregister run_on_change_callbacks end + # Indicates whether the registration is optional, mandatory or not required. + # + # @return [Symbol] See {Requirement}. def requirement return Requirement::NOT_REQUIRED unless product return Requirement::MANDATORY if product.repositories.none? @@ -109,7 +119,7 @@ def requirement Requirement::NOT_REQUIRED end - # callback when state changed like when different product is selected + # Callbacks to be called when registration changes (e.g., a different product is selected). def on_change(&block) @on_change_callbacks ||= [] @on_change_callbacks << block @@ -117,13 +127,19 @@ def on_change(&block) private + # @return [Agama::Software::Manager] attr_reader :software + # Currently selected product. + # + # @return [Agama::Software::Product, nil] def product software.product end - # E.g., "ALP-Dolomite-1-x86_64" + # Product name expected by SCC. + # + # @return [String] E.g., "ALP-Dolomite-1-x86_64". def target_distro v = product.version.to_s.split(".").first || "1" "#{product.id}-#{v}-#{Yast::Arch.rpm_arch}" diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index b3530fa01d..2c33d22a34 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -52,7 +52,7 @@ class Manager GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" private_constant :GPG_KEYS_GLOB - # Selected product + # Selected product. # # @return [Agama::Product, nil] attr_reader :product @@ -70,6 +70,8 @@ class Manager # @return [Agama::RepositoriesManager] attr_reader :repositories + # @param config [Agama::Config] + # @param logger [Logger] def initialize(config, logger) @config = config @logger = logger @@ -84,6 +86,11 @@ def initialize(config, logger) on_progress_change { logger.info(progress.to_s) } end + # Selects a product with the given id. + # + # @raise {ArgumentError} If id is unknown. + # + # @param id [String] def select_product(id) return if id == product&.id @@ -274,6 +281,9 @@ def registration # @return [Logger] attr_reader :logger + # Generates a list of products according to the information of the config file. + # + # @return [Array] def build_products ProductBuilder.new(config).build end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb index 244304bbed..bcbfba8003 100644 --- a/service/lib/agama/software/product.rb +++ b/service/lib/agama/software/product.rb @@ -25,26 +25,57 @@ module Agama module Software # Represents a product that Agama can install. class Product + # Product id. + # + # @return [String] attr_reader :id + # Name of the product to be display. + # + # @return [String] attr_accessor :display_name + # Description of the product. + # + # @return [String] attr_accessor :description + # Internal name of the product. This is relevant for registering the product. + # + # @return [String] attr_accessor :name + # Version of the product. This is relevant for registering the product. + # + # @return [String] E.g., "1.0". attr_accessor :version + # List of repositories. + # + # @return [Array] Empty if the product requires registration. attr_accessor :repositories + # Mandatory packages. + # + # @return [Array] attr_accessor :mandatory_packages + # Optional packages. + # + # @return [Array] attr_accessor :optional_packages + # Mandatory patterns. + # + # @return [Array] attr_accessor :mandatory_patterns + # Optional patterns. + # + # @return [Array] attr_accessor :optional_patterns + # @param id [string] Product id. def initialize(id) @id = id @repositories = [] diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb index 13a709b735..7982c37ade 100644 --- a/service/lib/agama/software/product_builder.rb +++ b/service/lib/agama/software/product_builder.rb @@ -25,10 +25,14 @@ module Agama module Software # Builds products from the information of a config file. class ProductBuilder + # @param config [Agama::Config] def initialize(config) @config = config end + # Builds the products. + # + # @return [Array] def build config.products.map do |id, attrs| data = product_data_from_config(id) @@ -52,6 +56,10 @@ def build # @return [Agama::Config] attr_reader :config + # Data from config, filtering by arch. + # + # @param id [String] + # @return [Hash] def product_data_from_config(id) { name: config.pure_data.dig(id, "software", "base_product"), From db7567ac822f0e41b860a2104eec40a212fcae56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 16 Oct 2023 11:41:33 +0100 Subject: [PATCH 40/97] [service] Rubocop --- service/lib/agama/registration.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 2dbfcbac1a..911e5d6d9c 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -199,10 +199,10 @@ def remove_service(service) # taken from https://github.com/yast/yast-registration/blob/master/src/lib/registration/url_helpers.rb#L109 def credentials_from_url(url) parsed_url = URI(url) - params = Hash[URI.decode_www_form(parsed_url.query)] + params = URI.decode_www_form(parsed_url.query).to_h params["credentials"] - rescue + rescue StandardError # if something goes wrong try to continue like if there is no credentials param nil end From ef1a0f296a869c159963e7c8dd1f1d238556496f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 11:41:06 +0100 Subject: [PATCH 41/97] [service] Minor fixes and tests --- service/lib/agama/config.rb | 26 +- service/lib/agama/software/manager.rb | 2 + service/lib/agama/software/product.rb | 2 - service/test/agama/config_test.rb | 128 +++++++- .../test/agama/dbus/software/manager_test.rb | 270 +++++++++-------- service/test/agama/software/manager_test.rb | 102 ++++--- .../agama/software/product_builder_test.rb | 286 ++++++++++++++++++ service/test/agama/with_issues_examples.rb | 2 - 8 files changed, 634 insertions(+), 184 deletions(-) create mode 100644 service/test/agama/software/product_builder_test.rb diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 8f447f2b5a..bbd9558ba7 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -121,15 +121,19 @@ def merge(config) # # @example # config.pure_data = { - # ALP-Dolomite: { - # software: { - # installation_repositories: { - # - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/ - # archs: x86_64 - # - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/ - # archs: aarch64 - # - https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/ - # } + # "ALP-Dolomite" => { + # "software" => { + # "installation_repositories" => [ + # { + # "url" => "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/", + # "archs" => "x86_64" + # }, + # { + # "url" => https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/", + # "archs" => "aarch64" + # }, + # "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/" + # ] # } # } # } @@ -146,7 +150,7 @@ def merge(config) def arch_elements_from(*keys, property: nil) keys.map!(&:to_s) elements = pure_data.dig(*keys) - return [] unless elements + return [] unless elements.is_a?(Array) elements.map do |element| if !element.is_a?(Hash) @@ -178,7 +182,7 @@ def simple_merge(a_hash, another_hash) # Whether the current arch matches any of the given archs. # - # @param archs [Array] + # @param archs [String] E.g., "x86_64,aarch64" # @return [Boolean] def arch_match?(archs) return true if archs.nil? diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 2c33d22a34..dd88d5881b 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -83,6 +83,8 @@ def initialize(config, logger) @user_patterns = [] @selected_patterns_change_callbacks = [] + update_issues + on_progress_change { logger.info(progress.to_s) } end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb index bcbfba8003..71f1ade8ac 100644 --- a/service/lib/agama/software/product.rb +++ b/service/lib/agama/software/product.rb @@ -19,8 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/software/repositories_manager" - module Agama module Software # Represents a product that Agama can install. diff --git a/service/test/agama/config_test.rb b/service/test/agama/config_test.rb index c61714ee9c..bdb7eea27f 100644 --- a/service/test/agama/config_test.rb +++ b/service/test/agama/config_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,8 +20,11 @@ # find current contact information at www.suse.com. require_relative "../test_helper" +require "yast" require "agama/config" +Yast.import "Arch" + describe Agama::Config do let(:config) { described_class.new("web" => { "ssl" => "SOMETHING" }) } @@ -119,4 +122,127 @@ end end end + + describe "#arch_elements_from" do + subject { described_class.new(data) } + + context "when the given set of keys does not match any data" do + let(:data) do + { + "Product1" => { + "name" => "Test product 1" + } + } + end + + it "returns an empty array" do + expect(subject.arch_elements_from("Product1", "some", "collection")).to be_empty + end + end + + context "when the given set of keys does not contain a collection" do + let(:data) do + { + "Product1" => { + "name" => "Test product 1" + } + } + end + + it "returns an empty array" do + expect(subject.arch_elements_from("Product1", "name")).to be_empty + end + end + + context "when the given set of keys contains a collection" do + let(:data) do + { + "Product1" => { + "some" => { + "collection" => [ + "element1", + { + "element" => "element2" + }, + { + "element" => "element3", + "archs" => "x86_64" + }, + { + "element" => "element4", + "archs" => "x86_64,aarch64" + }, + { + "element" => "element5", + "archs" => "ppc64" + } + ] + } + } + } + end + + before do + allow(Yast::Arch).to receive("x86_64").and_return(true) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + end + + it "returns all the elements that match the current arch" do + elements = subject.arch_elements_from("Product1", "some", "collection") + + expect(elements).to contain_exactly( + "element1", + { "element" => "element2" }, + { "element" => "element3", "archs" => "x86_64" }, + { "element" => "element4", "archs" => "x86_64,aarch64" } + ) + end + + context "and there are no elements matching the current arch" do + let(:data) do + { + "Product1" => { + "some" => { + "collection" => [ + { + "element" => "element1", + "archs" => "aarch64" + }, + { + "element" => "element2", + "archs" => "ppc64" + } + ] + } + } + } + end + + it "returns an empty list" do + elements = subject.arch_elements_from("Product1", "some", "collection") + + expect(elements).to be_empty + end + end + + context "and some property is requested" do + it "returns the property from all elements that match the current arch" do + elements = subject.arch_elements_from( + "Product1", "some", "collection", property: :element + ) + + expect(elements).to contain_exactly("element1", "element2", "element3", "element4") + end + end + + context "and the requested property does not exit" do + it "only return elements that are direct values" do + elements = subject.arch_elements_from("Product1", "some", "collection", property: :foo) + + expect(elements).to contain_exactly("element1") + end + end + end + end end diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index 9749fccec1..17a5504e4d 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -213,218 +213,220 @@ describe "#register" do before do - allow(backend).to receive(:product).and_return("Tumbleweed") allow(backend.registration).to receive(:reg_code).and_return(nil) end context "if there is no product selected yet" do - before do - allow(backend).to receive(:product).and_return(nil) - end - it "returns result code 1 and description" do expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i) end end - context "if the product is already registered" do + context "if there is a selected product" do before do - allow(backend.registration).to receive(:reg_code).and_return("123XX432") + backend.select_product("Tumbleweed") end - it "returns result code 2 and description" do - expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) - end - end + context "if the product is already registered" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end - context "if the registration is correctly done" do - before do - allow(backend.registration).to receive(:register) + it "returns result code 2 and description" do + expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) + end end - it "returns result code 0 without description" do - expect(subject.register("123XX432")).to contain_exactly(0, "") - end - end + context "if there is a network error" do + before do + allow(backend.registration).to receive(:register).and_raise(SocketError) + end - context "if there is a network error" do - before do - allow(backend.registration).to receive(:register).and_raise(SocketError) + it "returns result code 3 and description" do + expect(subject.register("123XX432")).to contain_exactly(3, /network error/) + end end - it "returns result code 3 and description" do - expect(subject.register("123XX432")).to contain_exactly(3, /network error/) - end - end + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:register).and_raise(Timeout::Error) + end - context "if there is a timeout" do - before do - allow(backend.registration).to receive(:register).and_raise(Timeout::Error) + it "returns result code 4 and description" do + expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) + end end - it "returns result code 4 and description" do - expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) - end - end + context "if there is an API error" do + before do + allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") + end - context "if there is an API error" do - before do - allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") + it "returns result code 5 and description" do + expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) + end end - it "returns result code 5 and description" do - expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) - end - end + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end - context "if there is a missing credials error" do - before do - allow(backend.registration) - .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) + it "returns result code 6 and description" do + expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) + end end - it "returns result code 6 and description" do - expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) - end - end + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end - context "if there is an incorrect credials error" do - before do - allow(backend.registration) - .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + it "returns result code 7 and description" do + expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) + end end - it "returns result code 7 and description" do - expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) - end - end + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) + end - context "if there is an invalid certificate error" do - before do - allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) + it "returns result code 8 and description" do + expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) + end end - it "returns result code 8 and description" do - expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) - end - end + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) + end - context "if there is an internal error" do - before do - allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) + it "returns result code 9 and description" do + expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) + end end - it "returns result code 9 and description" do - expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) + context "if the registration is correctly done" do + before do + allow(backend.registration).to receive(:register) + end + + it "returns result code 0 without description" do + expect(subject.register("123XX432")).to contain_exactly(0, "") + end end end end describe "#deregister" do before do - allow(backend).to receive(:product).and_return("Tumbleweed") allow(backend.registration).to receive(:reg_code).and_return("123XX432") end context "if there is no product selected yet" do - before do - allow(backend).to receive(:product).and_return(nil) - end - it "returns result code 1 and description" do expect(subject.deregister).to contain_exactly(1, /product not selected/i) end end - context "if the product is not registered yet" do + context "if there is a selected product" do before do - allow(backend.registration).to receive(:reg_code).and_return(nil) + backend.select_product("Tumbleweed") end - it "returns result code 2 and description" do - expect(subject.deregister).to contain_exactly(2, /product not registered/i) - end - end + context "if the product is not registered yet" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end - context "if the deregistration is correctly done" do - before do - allow(backend.registration).to receive(:deregister) + it "returns result code 2 and description" do + expect(subject.deregister).to contain_exactly(2, /product not registered/i) + end end - it "returns result code 0 without description" do - expect(subject.deregister).to contain_exactly(0, "") - end - end + context "if there is a network error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SocketError) + end - context "if there is a network error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(SocketError) + it "returns result code 3 and description" do + expect(subject.deregister).to contain_exactly(3, /network error/) + end end - it "returns result code 3 and description" do - expect(subject.deregister).to contain_exactly(3, /network error/) - end - end + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) + end - context "if there is a timeout" do - before do - allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) + it "returns result code 4 and description" do + expect(subject.deregister).to contain_exactly(4, /timeout/) + end end - it "returns result code 4 and description" do - expect(subject.deregister).to contain_exactly(4, /timeout/) - end - end + context "if there is an API error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") + end - context "if there is an API error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") + it "returns result code 5 and description" do + expect(subject.deregister).to contain_exactly(5, /registration server failed/) + end end - it "returns result code 5 and description" do - expect(subject.deregister).to contain_exactly(5, /registration server failed/) - end - end + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end - context "if there is a missing credials error" do - before do - allow(backend.registration) - .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) + it "returns result code 6 and description" do + expect(subject.deregister).to contain_exactly(6, /missing credentials/) + end end - it "returns result code 6 and description" do - expect(subject.deregister).to contain_exactly(6, /missing credentials/) - end - end + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end - context "if there is an incorrect credials error" do - before do - allow(backend.registration) - .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + it "returns result code 7 and description" do + expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) + end end - it "returns result code 7 and description" do - expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) - end - end + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) + end - context "if there is an invalid certificate error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) + it "returns result code 8 and description" do + expect(subject.deregister).to contain_exactly(8, /invalid certificate/) + end end - it "returns result code 8 and description" do - expect(subject.deregister).to contain_exactly(8, /invalid certificate/) - end - end + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) + end - context "if there is an internal error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) + it "returns result code 9 and description" do + expect(subject.deregister).to contain_exactly(9, /registration server failed/) + end end - it "returns result code 9 and description" do - expect(subject.deregister).to contain_exactly(9, /registration server failed/) + context "if the deregistration is correctly done" do + before do + allow(backend.registration).to receive(:deregister) + end + + it "returns result code 0 without description" do + expect(subject.deregister).to contain_exactly(0, "") + end end end end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 4095f91775..984d0d89b3 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -29,6 +29,7 @@ require "agama/issue" require "agama/registration" require "agama/software/manager" +require "agama/software/product" require "agama/software/proposal" require "agama/dbus/clients/questions" @@ -94,28 +95,78 @@ allow(Agama::Software::Proposal).to receive(:new).and_return(proposal) end - shared_examples "software issues" do |tested_method| + describe "#new" do before do - allow(subject).to receive(:product).and_return(product) - allow(subject.registration).to receive(:reg_code).and_return(reg_code) + allow_any_instance_of(Agama::Software::ProductBuilder) + .to receive(:build).and_return(products) end - let(:product) { "ALP-Dolomite" } - let(:reg_code) { "123XX432" } - let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] } + context "if there are several products" do + let(:products) do + [Agama::Software::Product.new("test1"), Agama::Software::Product.new("test2")] + end - context "if there is no product selected yet" do - let(:product) { nil } + it "does not select a product by default" do + manager = described_class.new(config, logger) - it "sets an issue" do - subject.public_send(tested_method) + expect(manager.product).to be_nil + end + + it "adds a not selected product issue" do + manager = described_class.new(config, logger) - expect(subject.issues).to contain_exactly(an_object_having_attributes( + expect(manager.issues).to contain_exactly(an_object_having_attributes( description: /product not selected/i )) end end + context "if there is only a product" do + let(:products) { [product] } + + let(:product) do + Agama::Software::Product.new("test1").tap { |p| p.repositories = product_repositories } + end + + let(:product_repositories) { [] } + + it "selects the product" do + manager = described_class.new(config, logger) + + expect(manager.product.id).to eq("test1") + end + + context "if the product requires registration" do + let(:product_repositories) { [] } + + it "adds a registration issue" do + manager = described_class.new(config, logger) + + expect(manager.issues).to include(an_object_having_attributes( + description: /product must be registered/i + )) + end + + context "if the product does not require registration" do + let(:product_repositories) { ["https://test"] } + + it "does not add issues" do + expect(subject.issues).to be_empty + end + end + end + end + end + + shared_examples "software issues" do |tested_method| + before do + subject.select_product("Tumbleweed") + allow(subject.registration).to receive(:reg_code).and_return(reg_code) + end + + let(:reg_code) { "123XX432" } + let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] } + context "if there are disabled repositories" do let(:disabled_repos) do [ @@ -204,6 +255,7 @@ stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir) stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir) FileUtils.mkdir_p(repos_dir) + subject.select_product("Tumbleweed") end after do @@ -242,11 +294,11 @@ it "returns the list of known products" do products = subject.products expect(products.size).to eq(3) - id, data = products.first - expect(id).to eq("Tumbleweed") - expect(data).to include( - "name" => "openSUSE Tumbleweed", - "description" => String + expect(products).to all(be_a(Agama::Software::Product)) + expect(products).to contain_exactly( + an_object_having_attributes(id: "Tumbleweed"), + an_object_having_attributes(id: "Leap Micro"), + an_object_having_attributes(id: "Leap") ) end end @@ -254,7 +306,6 @@ describe "#propose" do before do subject.select_product("Tumbleweed") - allow(Yast::Arch).to receive(:s390).and_return(false) end it "creates a new proposal for the selected product" do @@ -264,23 +315,6 @@ subject.propose end - it "adds the patterns and packages to install depending on the system architecture" do - expect(proposal).to receive(:set_resolvables) - .with("agama", :pattern, ["enhanced_base"]) - expect(proposal).to receive(:set_resolvables) - .with("agama", :pattern, ["optional_base"], optional: true) - expect(proposal).to receive(:set_resolvables) - .with("agama", :package, ["mandatory_pkg"]) - expect(proposal).to receive(:set_resolvables) - .with("agama", :package, ["optional_pkg"], optional: true) - subject.propose - - expect(Yast::Arch).to receive(:s390).and_return(true) - expect(proposal).to receive(:set_resolvables) - .with("agama", :package, ["mandatory_pkg", "mandatory_pkg_s390"]) - subject.propose - end - include_examples "software issues", "propose" end diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb new file mode 100644 index 0000000000..e3dd6a4345 --- /dev/null +++ b/service/test/agama/software/product_builder_test.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +# Copyright (c) [2022-2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "yast" +require "agama/config" +require "agama/software/product" +require "agama/software/product_builder" + +Yast.import "Arch" + +describe Agama::Software::ProductBuilder do + subject { described_class.new(config) } + + let(:config) { Agama::Config.new(data) } + + let(:data) do + { + "products" => { + "Test1" => { + "name" => "Product Test 1", + "description" => "This is a test product named Test 1" + }, + "Test2" => { + "name" => "Product Test 2", + "description" => "This is a test product named Test 2", + "archs" => "x86_64,aarch64" + }, + "Test3" => { + "name" => "Product Test 3", + "description" => "This is a test product named Test 3", + "archs" => "ppc64,aarch64" + } + }, + "Test1" => { + "software" => { + "installation_repositories" => [ + { + "url" => "https://repos/test1/x86_64/product/", + "archs" => "x86_64" + }, + { + "url" => "https://repos/test1/aarch64/product/", + "archs" => "aarch64" + } + ], + "mandatory_packages" => [ + { + "package" => "package1-1" + }, + "package1-2", + { + "package" => "package1-3", + "archs" => "aarch64,x86_64" + }, + { + "package" => "package1-4", + "archs" => "ppc64" + } + ], + "optional_packages" => ["package1-5"], + "mandatory_patterns" => ["pattern1-1", "pattern1-2"], + "optional_patterns" => [ + { + "pattern" => "pattern1-3", + "archs" => "x86_64" + }, + { + "pattern" => "pattern1-4", + "archs" => "aarch64" + } + ], + "base_product" => "Test1", + "version" => "1.0" + } + }, + "Test2" => { + "software" => { + "mandatory_patterns" => ["pattern2-1"], + "base_product" => "Test2", + "version" => "2.0" + } + }, + "Test3" => { + "software" => { + "installation_repositories" => ["https://repos/test3/product/"], + "optional_patterns" => [ + { + "pattern" => "pattern3-1", + "archs" => "aarch64" + } + ], + "base_product" => "Test3" + } + } + } + end + + describe "#build" do + context "for x86_64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(true) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: ["https://repos/test1/x86_64/product/"], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: ["pattern1-3"], + mandatory_packages: ["package1-1", "package1-2", "package1-3"], + optional_packages: ["package1-5"] + ), + an_object_having_attributes( + id: "Test2", + display_name: "Product Test 2", + description: "This is a test product named Test 2", + name: "Test2", + version: "2.0", + repositories: [], + mandatory_patterns: ["pattern2-1"], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [] + ) + ) + end + end + + context "for aarch64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(true) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: ["https://repos/test1/aarch64/product/"], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: ["pattern1-4"], + mandatory_packages: ["package1-1", "package1-2", "package1-3"], + optional_packages: ["package1-5"] + ), + an_object_having_attributes( + id: "Test2", + display_name: "Product Test 2", + description: "This is a test product named Test 2", + name: "Test2", + version: "2.0", + repositories: [], + mandatory_patterns: ["pattern2-1"], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [] + ), + an_object_having_attributes( + id: "Test3", + display_name: "Product Test 3", + description: "This is a test product named Test 3", + name: "Test3", + version: nil, + repositories: ["https://repos/test3/product/"], + mandatory_patterns: [], + optional_patterns: ["pattern3-1"], + mandatory_packages: [], + optional_packages: [] + ) + ) + end + end + + context "for ppc64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(true) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: [], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: [], + mandatory_packages: ["package1-1", "package1-2", "package1-4"], + optional_packages: ["package1-5"] + ), + an_object_having_attributes( + id: "Test3", + display_name: "Product Test 3", + description: "This is a test product named Test 3", + name: "Test3", + version: nil, + repositories: ["https://repos/test3/product/"], + mandatory_patterns: [], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [] + ) + ) + end + end + + context "for s390" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(true) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: [], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: [], + mandatory_packages: ["package1-1", "package1-2"], + optional_packages: ["package1-5"] + ) + ) + end + end + end +end diff --git a/service/test/agama/with_issues_examples.rb b/service/test/agama/with_issues_examples.rb index e3ce105822..5a68e8180b 100644 --- a/service/test/agama/with_issues_examples.rb +++ b/service/test/agama/with_issues_examples.rb @@ -27,8 +27,6 @@ let(:issues) { [Agama::Issue.new("Issue 1"), Agama::Issue.new("Issue 2")] } it "sets the given list of issues" do - expect(subject.issues).to be_empty - subject.issues = issues expect(subject.issues).to contain_exactly( From dfd547fc94a3ae6e30adbbc3dcbdaf258b19bc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 13:38:26 +0100 Subject: [PATCH 42/97] [service] Prevent change of product if needed - A new product cannot be selected before deregistering the current one. - D-Bus API for seleting a product now returns a code for success or error. --- service/lib/agama/dbus/software/manager.rb | 38 +++++++++++++----- .../test/agama/dbus/software/manager_test.rb | 39 ++++++++++++++++++- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 135c7442c3..adf4cd0cb3 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -71,17 +71,17 @@ def issues dbus_reader :selected_product, "s" - dbus_method :SelectProduct, "in ProductID:s" do |product_id| - old_product_id = backend.product&.id + dbus_method :SelectProduct, "in id:s, out result:(us)" do |id| + logger.info "Selecting product #{id}" - if old_product_id == product_id - logger.info "Do not changing the product as it is still the same (#{product_id})" - return + code, description = select_product(id) + + if code == 0 + dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedProduct" => id }, []) + dbus_properties_changed(REGISTRATION_INTERFACE, { "Requirement" => requirement }, []) end - logger.info "Selecting product #{product_id}" - select_product(product_id) - dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedProduct" => product_id }, []) + [[code, description]] end # value of result hash is category, description, icon, summary and order @@ -142,8 +142,26 @@ def selected_product backend.product&.id || "" end - def select_product(product_id) - backend.select_product(product_id) + # Selects a product. + # + # @param id [String] Product id. + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: already selected + # 2: deregister first + # 3: unknown product + def select_product(id) + if backend.product&.id == id + [1, "Product is already selected"] + elsif backend.registration.reg_code + [2, "Current product must be deregistered first"] + else + backend.select_product(id) + [0, ""] + end + rescue ArgumentError + [3, "Unknown product"] end def probe diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index 17a5504e4d..a8f6d04e8d 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -87,6 +87,41 @@ backend.issues = [] end + describe "select_product" do + context "if the product is correctly selected" do + it "returns result code 0 with empty description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(0, "") + end + end + + context "if the given product is already selected" do + before do + subject.select_product("Tumbleweed") + end + + it "returns result code 1 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(1, /already selected/) + end + end + + context "if the current product is registered" do + before do + subject.select_product("Leap") + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(2, /must be deregistered/) + end + end + + context "if the product is unknown" do + it "returns result code 3 and description" do + expect(subject.select_product("Unknown")).to contain_exactly(3, /unknown product/i) + end + end + end + describe "#probe" do it "runs the probing, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) @@ -314,7 +349,7 @@ allow(backend.registration).to receive(:register) end - it "returns result code 0 without description" do + it "returns result code 0 with empty description" do expect(subject.register("123XX432")).to contain_exactly(0, "") end end @@ -424,7 +459,7 @@ allow(backend.registration).to receive(:deregister) end - it "returns result code 0 without description" do + it "returns result code 0 with empty description" do expect(subject.deregister).to contain_exactly(0, "") end end From dd866c7996426d2e8b2d44166f4004ee896ce01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 15:48:56 +0100 Subject: [PATCH 43/97] [service] Document Registration D-Bus interface --- .../bus/org.opensuse.Agama.Software1.bus.xml | 39 ++++++++-- .../org.opensuse.Agama1.Registration.bus.xml | 1 + .../org.opensuse.Agama1.Registration.doc.xml | 75 +++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 120000 doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml create mode 100644 doc/dbus/org.opensuse.Agama1.Registration.doc.xml diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml index 82970322e0..44b5873a17 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml @@ -29,11 +29,21 @@ - + + - + + + + + + + + + + @@ -54,20 +64,33 @@ - - + + + + + + + + + + + + + + + + + + + - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml new file mode 120000 index 0000000000..d0feb248d1 --- /dev/null +++ b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml @@ -0,0 +1 @@ +org.opensuse.Agama.Software1.bus.xml \ No newline at end of file diff --git a/doc/dbus/org.opensuse.Agama1.Registration.doc.xml b/doc/dbus/org.opensuse.Agama1.Registration.doc.xml new file mode 100644 index 0000000000..60890ef53c --- /dev/null +++ b/doc/dbus/org.opensuse.Agama1.Registration.doc.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 6c4c742bfd0a5cd6fd752c32c52afc5ce06ba285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 16:05:37 +0100 Subject: [PATCH 44/97] [service] Partially document Software D-Bus interface --- doc/dbus/org.opensuse.Agama.Software1.doc.xml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 doc/dbus/org.opensuse.Agama.Software1.doc.xml diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml new file mode 100644 index 0000000000..927f671a8a --- /dev/null +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2bb1a00f87e65d8d49f08892faefcebd54e835d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 16:31:56 +0100 Subject: [PATCH 45/97] [service] Fix D-Bus documentation --- doc/dbus/org.opensuse.Agama.Software1.doc.xml | 2 ++ doc/dbus/org.opensuse.Agama1.Registration.doc.xml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml index 927f671a8a..82bc7e5208 100644 --- a/doc/dbus/org.opensuse.Agama.Software1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -1,5 +1,7 @@ + + From cc4ba9595ceb4ee8033003848b737dea1cabbe0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 17 Oct 2023 16:43:23 +0100 Subject: [PATCH 46/97] [web] Fix tests --- web/src/components/software/PatternSelector.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/software/PatternSelector.test.jsx b/web/src/components/software/PatternSelector.test.jsx index 58923ff724..3797997570 100644 --- a/web/src/components/software/PatternSelector.test.jsx +++ b/web/src/components/software/PatternSelector.test.jsx @@ -31,7 +31,7 @@ import PatternSelector from "./PatternSelector"; jest.mock("~/client"); const selectedPatternsFn = jest.fn().mockResolvedValue([]); const getUsedSpaceFn = jest.fn().mockResolvedValue(); -const getValidationErrorsFn = jest.fn().mockResolvedValue([]); +const getIssuesFn = jest.fn().mockResolvedValue([]); const patternsFn = jest.fn().mockResolvedValue(test_patterns); beforeEach(() => { @@ -40,7 +40,7 @@ beforeEach(() => { software: { selectedPatterns: selectedPatternsFn, getUsedSpace: getUsedSpaceFn, - getValidationErrors: getValidationErrorsFn, + getIssues: getIssuesFn, patterns: patternsFn }, }; From 88141e16a8b0d1b95273b9440b1e0bef5f6d76bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 18 Oct 2023 12:45:13 +0100 Subject: [PATCH 47/97] [rust] Adapt to Software D-Bus changes --- rust/agama-lib/src/error.rs | 2 ++ rust/agama-lib/src/software/client.rs | 17 +++++++++++--- rust/agama-lib/src/software/proxies.rs | 31 +++++++++++++++++++++----- rust/agama-lib/src/software/store.rs | 11 +++------ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index e9a9f205ca..5ecde908ca 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -18,6 +18,8 @@ pub enum ServiceError { Anyhow(#[from] anyhow::Error), #[error("Wrong user parameters: '{0:?}'")] WrongUser(Vec), + #[error("Result error ({0}): {1}")] + Result(u32, String), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 1a56faf9da..a4bdb05ee0 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -30,7 +30,7 @@ impl<'a> SoftwareClient<'a> { pub async fn products(&self) -> Result, ServiceError> { let products: Vec = self .software_proxy - .available_base_products() + .available_products() .await? .into_iter() .map(|(id, name, data)| { @@ -50,11 +50,22 @@ impl<'a> SoftwareClient<'a> { /// Returns the selected product to install pub async fn product(&self) -> Result { - Ok(self.software_proxy.selected_base_product().await?) + Ok(self.software_proxy.selected_product().await?) } /// Selects the product to install pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { - Ok(self.software_proxy.select_product(product_id).await?) + let result = self.software_proxy.select_product(product_id).await?; + + match result { + (0, _) => Ok(()), + (3, description) => { + let products = self.products().await?; + let ids: Vec = products.into_iter().map(|p| p.id).collect(); + let error = format!("{0}. Available products: '{1:?}'", description, ids); + Err(ServiceError::Result(3, error)) + } + (code, description) => Err(ServiceError::Result(code, description)), + } } } diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs index 8803fce495..b9721bbf4a 100644 --- a/rust/agama-lib/src/software/proxies.rs +++ b/rust/agama-lib/src/software/proxies.rs @@ -1,6 +1,6 @@ //! D-Bus interface proxies for: `org.opensuse.Agama.Software1.*` //! -//! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`. +//! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data. use zbus::dbus_proxy; #[dbus_proxy( @@ -9,6 +9,9 @@ use zbus::dbus_proxy; default_path = "/org/opensuse/Agama/Software1" )] trait Software1 { + /// AddPattern method + fn add_pattern(&self, id: &str) -> zbus::Result<()>; + /// Finish method fn finish(&self) -> zbus::Result<()>; @@ -18,6 +21,12 @@ trait Software1 { /// IsPackageInstalled method fn is_package_installed(&self, name: &str) -> zbus::Result; + /// ListPatterns method + fn list_patterns( + &self, + filtered: bool, + ) -> zbus::Result>; + /// Probe method fn probe(&self) -> zbus::Result<()>; @@ -27,15 +36,21 @@ trait Software1 { /// ProvisionsSelected method fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result>; + /// RemovePattern method + fn remove_pattern(&self, id: &str) -> zbus::Result<()>; + /// SelectProduct method - fn select_product(&self, product_id: &str) -> zbus::Result<()>; + fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; + + /// SetUserPatterns method + fn set_user_patterns(&self, ids: &[&str]) -> zbus::Result<()>; /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result; - /// AvailableBaseProducts property + /// AvailableProducts property #[dbus_proxy(property)] - fn available_base_products( + fn available_products( &self, ) -> zbus::Result< Vec<( @@ -45,9 +60,13 @@ trait Software1 { )>, >; - /// SelectedBaseProduct property + /// SelectedPatterns property + #[dbus_proxy(property)] + fn selected_patterns(&self) -> zbus::Result>; + + /// SelectedProduct property #[dbus_proxy(property)] - fn selected_base_product(&self) -> zbus::Result; + fn selected_product(&self) -> zbus::Result; } #[dbus_proxy( diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 45706ed531..9a8a5fb365 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -29,15 +29,10 @@ impl<'a> SoftwareStore<'a> { pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> { if let Some(product) = &settings.product { - let products = self.software_client.products().await?; - let ids: Vec = products.into_iter().map(|p| p.id).collect(); - if ids.contains(product) { - self.software_client.select_product(product).await?; - self.manager_client.probe().await?; - } else { - return Err(ServiceError::UnknownProduct(product.clone(), ids)); - } + self.software_client.select_product(product).await?; + self.manager_client.probe().await?; } + Ok(()) } } From 4032595112ba54b6fdde482c8226916bd6e1e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 18 Oct 2023 15:40:00 +0100 Subject: [PATCH 48/97] [rust] Use better error name --- rust/agama-lib/src/error.rs | 6 ++---- rust/agama-lib/src/software/client.rs | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 5ecde908ca..7124e00cbb 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -10,16 +10,14 @@ pub enum ServiceError { DBus(#[from] zbus::Error), #[error("Could not connect to Agama bus at '{0}'")] DBusConnectionError(String, #[source] zbus::Error), - #[error("Unknown product '{0}'. Available products: '{1:?}'")] - UnknownProduct(String, Vec), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] Anyhow(#[from] anyhow::Error), #[error("Wrong user parameters: '{0:?}'")] WrongUser(Vec), - #[error("Result error ({0}): {1}")] - Result(u32, String), + #[error("Error: {0}")] + UnsuccessfulAction(String), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index a4bdb05ee0..8e90b5a474 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -63,9 +63,9 @@ impl<'a> SoftwareClient<'a> { let products = self.products().await?; let ids: Vec = products.into_iter().map(|p| p.id).collect(); let error = format!("{0}. Available products: '{1:?}'", description, ids); - Err(ServiceError::Result(3, error)) + Err(ServiceError::UnsuccessfulAction(error)) } - (code, description) => Err(ServiceError::Result(code, description)), + (_, description) => Err(ServiceError::UnsuccessfulAction(description)), } } } From 70b2def848ea87fc37076ebd4005f4692ad1589e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 18 Oct 2023 21:35:59 +0200 Subject: [PATCH 49/97] move service operations to software and fix target init --- service/lib/agama/registration.rb | 51 ++------------------------- service/lib/agama/software/manager.rb | 48 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 911e5d6d9c..7e1d9369b6 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -80,7 +80,7 @@ def register(code, email: "") SUSE::Connect::YaST.create_credentials_file(login, password, @credentials_file) end Y2Packager::NewRepositorySetup.instance.add_service(@service.name) - add_service(@service) + @software.add_service(@service) @reg_code = code @email = email @@ -89,7 +89,7 @@ def register(code, email: "") def deregister Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) - remove_service(@service) + @software.remove_service(@service) connect_params = { token: reg_code, @@ -149,53 +149,6 @@ def run_on_change_callbacks @on_change_callbacks&.map(&:call) end - # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 - # TODO: move it to software manager - # rubocop:disable Metrics/AbcSize - def add_service(service) - # save repositories before refreshing added services (otherwise - # pkg-bindings will treat them as removed by the service refresh and - # unload them) - if !Yast::Pkg.SourceSaveAll - # error message - @logger.error("Saving repository configuration failed.") - end - - @logger.info "Adding service #{service.name.inspect} (#{service.url})" - if !Yast::Pkg.ServiceAdd(service.name, service.url.to_s) - raise format("Adding service '%s' failed.", service.name) - end - - if !Yast::Pkg.ServiceSet(service.name, "autorefresh" => true) - # error message - raise format("Updating service '%s' failed.", service.name) - end - - # refresh works only for saved services - if !Yast::Pkg.ServiceSave(service.name) - # error message - raise format("Saving service '%s' failed.", service.name) - end - - # Force refreshing due timing issues (bnc#967828) - if !Yast::Pkg.ServiceForceRefresh(service.name) - # error message - raise format("Refreshing service '%s' failed.", service.name) - end - ensure - Yast::Pkg.SourceSaveAll - end - # rubocop:enable Metrics/AbcSize - - # TODO: move it to software manager - def remove_service(service) - if Yast::Pkg.ServiceDelete(service.name) && !Pkg.SourceSaveAll - raise format("Removing service '%s' failed.", service_name) - end - - true - end - # taken from https://github.com/yast/yast-registration/blob/master/src/lib/registration/url_helpers.rb#L109 def credentials_from_url(url) parsed_url = URI(url) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index dd88d5881b..107606364c 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -275,6 +275,54 @@ def registration @registration ||= Registration.new(self, @logger) end + # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 + # rubocop:disable Metrics/AbcSize + def add_service(service) + # init repos, so we are sure we operate on "/" and have GPG imported + initialize_target_repos + # save repositories before refreshing added services (otherwise + # pkg-bindings will treat them as removed by the service refresh and + # unload them) + if !Yast::Pkg.SourceSaveAll + # error message + @logger.error("Saving repository configuration failed.") + end + + @logger.info "Adding service #{service.name.inspect} (#{service.url})" + if !Yast::Pkg.ServiceAdd(service.name, service.url.to_s) + raise format("Adding service '%s' failed.", service.name) + end + + if !Yast::Pkg.ServiceSet(service.name, "autorefresh" => true) + # error message + raise format("Updating service '%s' failed.", service.name) + end + + # refresh works only for saved services + if !Yast::Pkg.ServiceSave(service.name) + # error message + raise format("Saving service '%s' failed.", service.name) + end + + # Force refreshing due timing issues (bnc#967828) + if !Yast::Pkg.ServiceForceRefresh(service.name) + # error message + raise format("Refreshing service '%s' failed.", service.name) + end + ensure + Yast::Pkg.SourceSaveAll + end + # rubocop:enable Metrics/AbcSize + + def remove_service(service) + if Yast::Pkg.ServiceDelete(service.name) && !Pkg.SourceSaveAll + raise format("Removing service '%s' failed.", service_name) + end + + true + end + + private # @return [Agama::Config] From 5a38dac3ee5ee6ea1963ed2f0c033a0b13aa6838 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 18 Oct 2023 21:50:33 +0200 Subject: [PATCH 50/97] fix namespace --- service/lib/agama/software/manager.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 107606364c..ef1a1be3b6 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -315,7 +315,7 @@ def add_service(service) # rubocop:enable Metrics/AbcSize def remove_service(service) - if Yast::Pkg.ServiceDelete(service.name) && !Pkg.SourceSaveAll + if Yast::Pkg.ServiceDelete(service.name) && !Yast::Pkg.SourceSaveAll raise format("Removing service '%s' failed.", service_name) end From 254dfe4ced069c383e71943b1188d9c8a4d7c72e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 18 Oct 2023 21:58:11 +0200 Subject: [PATCH 51/97] add dependency on suse connect it is used --- service/package/gem2rpm.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index cc70ad5bc8..b0e012ffa8 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -28,6 +28,8 @@ Requires: open-iscsi Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-users + # required for registration + Requires: suseconnect-ruby-bindings # yast2 with ArchFilter Requires: yast2 >= 4.5.20 %ifarch s390 s390x From 6c6cba26411cc1c2b276d74c8360f948259e6816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 19 Oct 2023 15:28:56 +0100 Subject: [PATCH 52/97] [service] Export a separate product object --- service/lib/agama/dbus/clients/software.rb | 12 +- service/lib/agama/dbus/clients/with_issues.rb | 24 +- service/lib/agama/dbus/software.rb | 3 +- service/lib/agama/dbus/software/manager.rb | 201 +----------- service/lib/agama/dbus/software/product.rb | 285 ++++++++++++++++++ service/lib/agama/dbus/software_service.rb | 1 + service/lib/agama/software/manager.rb | 46 ++- 7 files changed, 346 insertions(+), 226 deletions(-) create mode 100644 service/lib/agama/dbus/software/product.rb diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb index 5d9321698a..8f94786773 100644 --- a/service/lib/agama/dbus/clients/software.rb +++ b/service/lib/agama/dbus/clients/software.rb @@ -42,6 +42,9 @@ def initialize @dbus_object = service["/org/opensuse/Agama/Software1"] @dbus_object.introspect + @dbus_product = service["/org/opensuse/Agama/Software1/Product"] + @dbus_product.introspect + @dbus_proposal = service["/org/opensuse/Agama/Software1/Proposal"] @dbus_proposal.introspect end @@ -55,7 +58,7 @@ def service_name # # @return [Array>] name and display name of each product def available_products - dbus_object["org.opensuse.Agama.Software1"]["AvailableProducts"].map do |l| + dbus_product["org.opensuse.Agama.Software1.Product"]["AvailableProducts"].map do |l| l[0..1] end end @@ -64,7 +67,7 @@ def available_products # # @return [String, nil] name of the product def selected_product - product = dbus_object["org.opensuse.Agama.Software1"]["SelectedProduct"] + product = dbus_product["org.opensuse.Agama.Software1.Product"]["SelectedProduct"] return nil if product.empty? product @@ -74,7 +77,7 @@ def selected_product # # @param name [String] def select_product(name) - dbus_object.SelectProduct(name) + dbus_product.SelectProduct(name) end # Starts the probing process @@ -180,6 +183,9 @@ def on_product_selected(&block) # @return [::DBus::Object] attr_reader :dbus_object + # @return [::DBus::Object] + attr_reader :dbus_product + # @return [::DBus::Object] attr_reader :dbus_proposal end diff --git a/service/lib/agama/dbus/clients/with_issues.rb b/service/lib/agama/dbus/clients/with_issues.rb index a4a1dc363e..cc7ba3ea4e 100644 --- a/service/lib/agama/dbus/clients/with_issues.rb +++ b/service/lib/agama/dbus/clients/with_issues.rb @@ -28,10 +28,25 @@ module WithIssues ISSUES_IFACE = "org.opensuse.Agama1.Issues" private_constant :ISSUES_IFACE - # Returns the issues + # Returns issues from all objects that implement the issues interface. # # @return [Array] def issues + objects_with_issues_interface.map { |o| issues_from(o) }.flatten + end + + # Determines whether there are errors + # + # @return [Boolean] + def errors? + issues.any?(&:error?) + end + + def objects_with_issues_interface + service.root.descendant_objects.select { |o| o.interfaces.include?(ISSUES_IFACE) } + end + + def issues_from(dbus_object) sources = [nil, Issue::Source::SYSTEM, Issue::Source::CONFIG] severities = [Issue::Severity::WARN, Issue::Severity::ERROR] @@ -42,13 +57,6 @@ def issues severity: severities[dbus_issue[3]]) end end - - # Determines whether there are errors - # - # @return [Boolean] - def errors? - issues.any?(&:error?) - end end end end diff --git a/service/lib/agama/dbus/software.rb b/service/lib/agama/dbus/software.rb index 670cc4517e..ace66f64fc 100644 --- a/service/lib/agama/dbus/software.rb +++ b/service/lib/agama/dbus/software.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -28,4 +28,5 @@ module Software end require "agama/dbus/software/manager" +require "agama/dbus/software/product" require "agama/dbus/software/proposal" diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index adf4cd0cb3..06df099ee3 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "dbus" -require "suse/connect" require "agama/dbus/base_object" require "agama/dbus/clients/locale" require "agama/dbus/clients/network" @@ -28,7 +27,6 @@ require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" require "agama/dbus/with_service_status" -require "agama/registration" module Agama module DBus @@ -56,7 +54,7 @@ def initialize(backend, logger) @selected_patterns = {} end - # List of issues, see {DBus::Interfaces::Issues} + # List of software related issues, see {DBus::Interfaces::Issues} # # @return [Array] def issues @@ -67,23 +65,6 @@ def issues private_constant :SOFTWARE_INTERFACE dbus_interface SOFTWARE_INTERFACE do - dbus_reader :available_products, "a(ssa{sv})" - - dbus_reader :selected_product, "s" - - dbus_method :SelectProduct, "in id:s, out result:(us)" do |id| - logger.info "Selecting product #{id}" - - code, description = select_product(id) - - if code == 0 - dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedProduct" => id }, []) - dbus_properties_changed(REGISTRATION_INTERFACE, { "Requirement" => requirement }, []) - end - - [[code, description]] - end - # value of result hash is category, description, icon, summary and order dbus_method :ListPatterns, "in Filtered:b, out Result:a{s(sssss)}" do |filtered| [ @@ -129,41 +110,6 @@ def issues dbus_method(:Finish) { finish } end - def available_products - backend.products.map do |product| - [product.id, product.display_name, { "description" => product.description }] - end - end - - # Returns the selected base product - # - # @return [String] Product ID or an empty string if no product is selected - def selected_product - backend.product&.id || "" - end - - # Selects a product. - # - # @param id [String] Product id. - # @return [Array(Integer, String)] Result code and a description. - # Possible result codes: - # 0: success - # 1: already selected - # 2: deregister first - # 3: unknown product - def select_product(id) - if backend.product&.id == id - [1, "Product is already selected"] - elsif backend.registration.reg_code - [2, "Current product must be deregistered first"] - else - backend.select_product(id) - [0, ""] - end - rescue ArgumentError - [3, "Unknown product"] - end - def probe busy_while { backend.probe } end @@ -182,103 +128,6 @@ def finish busy_while { backend.finish } end - REGISTRATION_INTERFACE = "org.opensuse.Agama1.Registration" - private_constant :REGISTRATION_INTERFACE - - dbus_interface REGISTRATION_INTERFACE do - dbus_reader(:reg_code, "s") - - dbus_reader(:email, "s") - - dbus_reader(:requirement, "u") - - dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args| - [register(args[0], email: args[1]["Email"])] - end - - dbus_method(:Deregister, "out result:(us)") { [deregister] } - end - - def reg_code - backend.registration.reg_code || "" - end - - def email - backend.registration.email || "" - end - - # Registration requirement. - # - # @return [Integer] Possible values: - # 0: not required - # 1: optional - # 2: mandatory - def requirement - case backend.registration.requirement - when Agama::Registration::Requirement::MANDATORY - 2 - when Agama::Registration::Requirement::OPTIONAL - 1 - else - 0 - end - end - - # Tries to register with the given registration code. - # - # @param reg_code [String] - # @param email [String, nil] - # - # @return [Array(Integer, String)] Result code and a description. - # Possible result codes: - # 0: success - # 1: missing product - # 2: already registered - # 3: network error - # 4: timeout error - # 5: api error - # 6: missing credentials - # 7: incorrect credentials - # 8: invalid certificate - # 9: internal error (e.g., parsing json data) - def register(reg_code, email: nil) - if !backend.product - [1, "Product not selected yet"] - elsif backend.registration.reg_code - [2, "Product already registered"] - else - connect_result(first_error_code: 3) do - backend.registration.register(reg_code, email: email) - end - end - end - - # Tries to deregister. - # - # @return [Array(Integer, String)] Result code and a description. - # Possible result codes: - # 0: success - # 1: missing product - # 2: not registered yet - # 3: network error - # 4: timeout error - # 5: api error - # 6: missing credentials - # 7: incorrect credentials - # 8: invalid certificate - # 9: internal error (e.g., parsing json data) - def deregister - if !backend.product - [1, "Product not selected yet"] - elsif !backend.registration.reg_code - [2, "Product not registered yet"] - else - connect_result(first_error_code: 3) do - backend.registration.deregister - end - end - end - private # @return [Agama::Software] @@ -300,8 +149,6 @@ def register_callbacks self.selected_patterns = compute_patterns end - backend.registration.on_change { registration_properties_changed } - backend.on_issues_change { issues_properties_changed } end @@ -315,52 +162,6 @@ def compute_patterns patterns end - - def registration_properties_changed - dbus_properties_changed(REGISTRATION_INTERFACE, - interfaces_and_properties[REGISTRATION_INTERFACE], []) - end - - # Result from calling to SUSE connect. - # - # @raise [Exception] if an unexpected error is found. - # - # @return [Array(Integer, String)] List including a result code and a description - # (e.g., [1, "Connection to registration server failed (network error)"]). - def connect_result(first_error_code: 1, &block) - block.call - [0, ""] - rescue SocketError => e - connect_result_from_error(e, first_error_code, "network error") - rescue Timeout::Error => e - connect_result_from_error(e, first_error_code + 1, "timeout") - rescue SUSE::Connect::ApiError => e - connect_result_from_error(e, first_error_code + 2) - rescue SUSE::Connect::MissingSccCredentialsFile => e - connect_result_from_error(e, first_error_code + 3, "missing credentials") - rescue SUSE::Connect::MalformedSccCredentialsFile => e - connect_result_from_error(e, first_error_code + 4, "incorrect credentials") - rescue OpenSSL::SSL::SSLError => e - connect_result_from_error(e, first_error_code + 5, "invalid certificate") - rescue JSON::ParserError => e - connect_result_from_error(e, first_error_code + 6) - end - - # Generates a result from a given error. - # - # @param error [Exception] - # @param error_code [Integer] - # @param details [String, nil] - # - # @return [Array(Integer, String)] List including an error code and a description. - def connect_result_from_error(error, error_code, details = nil) - logger.error("Error connecting to registration server: #{error}") - - description = "Connection to registration server failed" - description += " (#{details})" if details - - [error_code, description] - end end end end diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb new file mode 100644 index 0000000000..2029d9c2a7 --- /dev/null +++ b/service/lib/agama/dbus/software/product.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" +require "suse/connect" +require "agama/dbus/base_object" +require "agama/dbus/interfaces/issues" +require "agama/registration" + +module Agama + module DBus + module Software + # D-Bus object to manage product configuration + class Product < BaseObject + include Interfaces::Issues + + PATH = "/org/opensuse/Agama/Software1/Product" + private_constant :PATH + + # Constructor + # + # @param backend [Agama::Software] + # @param logger [Logger] + def initialize(backend, logger) + super(PATH, logger: logger) + @backend = backend + @logger = logger + register_callbacks + end + + # List of issues, see {DBus::Interfaces::Issues} + # + # @return [Array] + def issues + backend.product_issues + end + + def available_products + backend.products.map do |product| + [product.id, product.display_name, { "description" => product.description }] + end + end + + # Returns the selected base product + # + # @return [String] Product ID or an empty string if no product is selected + def selected_product + backend.product&.id || "" + end + + # Selects a product. + # + # @param id [String] Product id. + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: already selected + # 2: deregister first + # 3: unknown product + def select_product(id) + if backend.product&.id == id + [1, "Product is already selected"] + elsif backend.registration.reg_code + [2, "Current product must be deregistered first"] + else + backend.select_product(id) + [0, ""] + end + rescue ArgumentError + [3, "Unknown product"] + end + + PRODUCT_INTERFACE = "org.opensuse.Agama.Software1.Product" + private_constant :PRODUCT_INTERFACE + + dbus_interface PRODUCT_INTERFACE do + dbus_reader :available_products, "a(ssa{sv})" + + dbus_reader :selected_product, "s" + + dbus_method :SelectProduct, "in id:s, out result:(us)" do |id| + logger.info "Selecting product #{id}" + + code, description = select_product(id) + + if code == 0 + dbus_properties_changed(PRODUCT_INTERFACE, { "SelectedProduct" => id }, []) + dbus_properties_changed(REGISTRATION_INTERFACE, { "Requirement" => requirement }, []) + # FIXME: Product issues might change after selecting a product. Nevertheless, + # #on_issues_change callbacks should be used for emitting issues signals, ensuring + # they are emitted every time the backend changes its issues. Currently, + # #on_issues_change cannot be used for product issues. Note that Software::Manager + # backend takes care of both software and product issues. And it already uses + # #on_issues_change callbacks for software related issues. + issues_properties_changed + end + + [[code, description]] + end + end + + def reg_code + backend.registration.reg_code || "" + end + + def email + backend.registration.email || "" + end + + # Registration requirement. + # + # @return [Integer] Possible values: + # 0: not required + # 1: optional + # 2: mandatory + def requirement + case backend.registration.requirement + when Agama::Registration::Requirement::MANDATORY + 2 + when Agama::Registration::Requirement::OPTIONAL + 1 + else + 0 + end + end + + # Tries to register with the given registration code. + # + # @param reg_code [String] + # @param email [String, nil] + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: already registered + # 3: network error + # 4: timeout error + # 5: api error + # 6: missing credentials + # 7: incorrect credentials + # 8: invalid certificate + # 9: internal error (e.g., parsing json data) + def register(reg_code, email: nil) + if !backend.product + [1, "Product not selected yet"] + elsif backend.registration.reg_code + [2, "Product already registered"] + else + connect_result(first_error_code: 3) do + backend.registration.register(reg_code, email: email) + end + end + end + + # Tries to deregister. + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: not registered yet + # 3: network error + # 4: timeout error + # 5: api error + # 6: missing credentials + # 7: incorrect credentials + # 8: invalid certificate + # 9: internal error (e.g., parsing json data) + def deregister + if !backend.product + [1, "Product not selected yet"] + elsif !backend.registration.reg_code + [2, "Product not registered yet"] + else + connect_result(first_error_code: 3) do + backend.registration.deregister + end + end + end + + REGISTRATION_INTERFACE = "org.opensuse.Agama1.Registration" + private_constant :REGISTRATION_INTERFACE + + dbus_interface REGISTRATION_INTERFACE do + dbus_reader(:reg_code, "s") + + dbus_reader(:email, "s") + + dbus_reader(:requirement, "u") + + dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args| + [register(args[0], email: args[1]["Email"])] + end + + dbus_method(:Deregister, "out result:(us)") { [deregister] } + end + + private + + # @return [Agama::Software] + attr_reader :backend + + # @return [Logger] + attr_reader :logger + + # Registers callback to be called + def register_callbacks + # FIXME: Product issues might change after changing the registration. Nevertheless, + # #on_issues_change callbacks should be used for emitting issues signals, ensuring they + # are emitted every time the backend changes its issues. Currently, #on_issues_change + # cannot be used for product issues. Note that Software::Manager backend takes care of + # both software and product issues. And it already uses #on_issues_change callbacks for + # software related issues. + backend.registration.on_change { issues_properties_changed } + backend.registration.on_change { registration_properties_changed } + end + + def registration_properties_changed + dbus_properties_changed(REGISTRATION_INTERFACE, + interfaces_and_properties[REGISTRATION_INTERFACE], []) + end + + # Result from calling to SUSE connect. + # + # @raise [Exception] if an unexpected error is found. + # + # @return [Array(Integer, String)] List including a result code and a description + # (e.g., [1, "Connection to registration server failed (network error)"]). + def connect_result(first_error_code: 1, &block) + block.call + [0, ""] + rescue SocketError => e + connect_result_from_error(e, first_error_code, "network error") + rescue Timeout::Error => e + connect_result_from_error(e, first_error_code + 1, "timeout") + rescue SUSE::Connect::ApiError => e + connect_result_from_error(e, first_error_code + 2) + rescue SUSE::Connect::MissingSccCredentialsFile => e + connect_result_from_error(e, first_error_code + 3, "missing credentials") + rescue SUSE::Connect::MalformedSccCredentialsFile => e + connect_result_from_error(e, first_error_code + 4, "incorrect credentials") + rescue OpenSSL::SSL::SSLError => e + connect_result_from_error(e, first_error_code + 5, "invalid certificate") + rescue JSON::ParserError => e + connect_result_from_error(e, first_error_code + 6) + end + + # Generates a result from a given error. + # + # @param error [Exception] + # @param error_code [Integer] + # @param details [String, nil] + # + # @return [Array(Integer, String)] List including an error code and a description. + def connect_result_from_error(error, error_code, details = nil) + logger.error("Error connecting to registration server: #{error}") + + description = "Connection to registration server failed" + description += " (#{details})" if details + + [error_code, description] + end + end + end + end +end diff --git a/service/lib/agama/dbus/software_service.rb b/service/lib/agama/dbus/software_service.rb index a2d7100ec9..1ce788a18e 100644 --- a/service/lib/agama/dbus/software_service.rb +++ b/service/lib/agama/dbus/software_service.rb @@ -83,6 +83,7 @@ def service def dbus_objects @dbus_objects ||= [ Agama::DBus::Software::Manager.new(@backend, logger), + Agama::DBus::Software::Product.new(@backend, logger), Agama::DBus::Software::Proposal.new(logger) ] end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index ef1a1be3b6..e2d6167cea 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -43,7 +43,15 @@ module Agama module Software - # This class is responsible for software handling + # This class is responsible for software handling. + # + # FIXME: This class has too many responsibilities: + # * Address the software service workflow (probe, propose, install). + # * Manages repositories, packages, patterns, services. + # * Manages product selection. + # * Manages software and product related issues. + # + # It shoud be splitted in separate and smaller classes. class Manager include Helpers include WithIssues @@ -322,6 +330,17 @@ def remove_service(service) true end + # Issues associated to the product. + # + # These issues are not considered as software issues, see {#update_issues}. + # + # @return [Array] + def product_issues + issues = [] + issues << missing_product_issue unless product + issues << missing_registration_issue if missing_registration? + issues + end private @@ -391,16 +410,16 @@ def selected_patterns_changed @selected_patterns_change_callbacks.each(&:call) end - # Updates the list of issues. + # Updates the list of software issues. def update_issues self.issues = current_issues end - # List of current issues. + # List of current software issues. # # @return [Array] def current_issues - return [missing_product_issue] unless product + return [] unless product issues = repos_issues @@ -408,19 +427,9 @@ def current_issues # packages. Those issues does not make any sense if there are no repositories to install # from. issues += proposal.issues if repositories.enabled.any? - issues << missing_registration_issue if missing_registration? issues end - # Issue when a product is missing - # - # @return [Agama::Issue] - def missing_product_issue - Issue.new("Product not selected yet", - source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR) - end - # Issues related to the software proposal. # # Repositories that could not be probed are reported as errors. @@ -434,6 +443,15 @@ def repos_issues end end + # Issue when a product is missing + # + # @return [Agama::Issue] + def missing_product_issue + Issue.new("Product not selected yet", + source: Issue::Source::CONFIG, + severity: Issue::Severity::ERROR) + end + # Issue when a product requires registration but it is not registered yet. # # @return [Agama::Issue] From fe0d63b6c8f3f367d05028d11e96dd943de4cb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 19 Oct 2023 17:37:48 +0100 Subject: [PATCH 53/97] [web] Adapt software client --- web/src/client/software.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index 081f1ff1fe..193f90575e 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -27,6 +27,8 @@ import { WithIssues, WithStatus, WithProgress } from "./mixins"; const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1"; const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; +const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; +const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; /** * @typedef {object} Product @@ -64,7 +66,7 @@ class SoftwareBaseClient { * @return {Promise>} */ async getProducts() { - const proxy = await this.client.proxy(SOFTWARE_IFACE); + const proxy = await this.client.proxy(PRODUCT_IFACE); return proxy.AvailableProducts.map(product => { const [id, name, meta] = product; return { id, name, description: meta.description?.v }; @@ -136,7 +138,7 @@ class SoftwareBaseClient { */ async getSelectedProduct() { const products = await this.getProducts(); - const proxy = await this.client.proxy(SOFTWARE_IFACE); + const proxy = await this.client.proxy(PRODUCT_IFACE); if (proxy.SelectedProduct === "") { return null; } @@ -149,7 +151,7 @@ class SoftwareBaseClient { * @param {string} id - product ID */ async selectProduct(id) { - const proxy = await this.client.proxy(SOFTWARE_IFACE); + const proxy = await this.client.proxy(PRODUCT_IFACE); return proxy.SelectProduct(id); } @@ -159,7 +161,7 @@ class SoftwareBaseClient { * @param {(id: string) => void} handler - callback function */ onProductChange(handler) { - return this.client.onObjectChanged(SOFTWARE_PATH, SOFTWARE_IFACE, changes => { + return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { if ("SelectedProduct" in changes) { const selected = changes.SelectedProduct.v.toString(); handler(selected); From 7b1cd3cc8c969f2bab4a01e684ec3797999fc743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 11:35:08 +0100 Subject: [PATCH 54/97] [service] Unit tests and minor fixes --- service/lib/agama/dbus/clients/software.rb | 2 +- service/lib/agama/dbus/software/product.rb | 8 +- service/lib/agama/registration.rb | 21 +- service/lib/agama/software/manager.rb | 3 - .../test/agama/dbus/clients/software_test.rb | 34 +- .../dbus/clients/with_issues_examples.rb | 61 ++- .../test/agama/dbus/software/manager_test.rb | 329 --------------- .../test/agama/dbus/software/product_test.rb | 388 ++++++++++++++++++ service/test/agama/software/manager_test.rb | 143 ++++--- 9 files changed, 560 insertions(+), 429 deletions(-) create mode 100644 service/test/agama/dbus/software/product_test.rb diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb index 8f94786773..9ff10a4fc6 100644 --- a/service/lib/agama/dbus/clients/software.rb +++ b/service/lib/agama/dbus/clients/software.rb @@ -172,7 +172,7 @@ def remove_resolvables(unique_id, type, resolvables, optional: false) # # @param block [Proc] Callback to run when a product is selected def on_product_selected(&block) - on_properties_change(dbus_object) do |_, changes, _| + on_properties_change(dbus_product) do |_, changes, _| product = changes["SelectedProduct"] block.call(product) unless product.nil? end diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 2029d9c2a7..475107f75b 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -28,15 +28,13 @@ module Agama module DBus module Software - # D-Bus object to manage product configuration + # D-Bus object to manage product configuration. class Product < BaseObject include Interfaces::Issues PATH = "/org/opensuse/Agama/Software1/Product" private_constant :PATH - # Constructor - # # @param backend [Agama::Software] # @param logger [Logger] def initialize(backend, logger) @@ -46,7 +44,7 @@ def initialize(backend, logger) register_callbacks end - # List of issues, see {DBus::Interfaces::Issues} + # List of issues, see {DBus::Interfaces::Issues}. # # @return [Array] def issues @@ -59,7 +57,7 @@ def available_products end end - # Returns the selected base product + # Returns the selected base product. # # @return [String] Product ID or an empty string if no product is selected def selected_product diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 7e1d9369b6..64c64bcecc 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -53,6 +53,16 @@ def initialize(software_manager, logger) @logger = logger end + # Registers the selected product. + # + # @raise [ + # SocketError|Timeout::Error|SUSE::Connect::ApiError| + # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile| + # OpenSSL::SSL::SSLError|JSON::ParserError + # ] + # + # @param code [String] Registration code. + # @param email [String] Email for registering the product. def register(code, email: "") return unless product @@ -66,7 +76,6 @@ def register(code, email: "") # TODO: check if we can do it in memory for libzypp SUSE::Connect::YaST.create_credentials_file(login, password) - # TODO: fill it properly for scc target_product = OpenStruct.new( arch: Yast::Arch.rpm_arch, identifier: product.id, @@ -87,6 +96,15 @@ def register(code, email: "") run_on_change_callbacks end + # Deregisters the selected product. + # + # It uses the registration code and email passed to {#register}. + # + # @raise [ + # SocketError|Timeout::Error|SUSE::Connect::ApiError| + # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile| + # OpenSSL::SSL::SSLError|JSON::ParserError + # ] def deregister Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) @software.remove_service(@service) @@ -103,7 +121,6 @@ def deregister @credentials_file = nil end - # reset variables here @reg_code = nil @email = nil run_on_change_callbacks diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index e2d6167cea..6f819f99dc 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -90,9 +90,6 @@ def initialize(config, logger) # patterns selected by user @user_patterns = [] @selected_patterns_change_callbacks = [] - - update_issues - on_progress_change { logger.info(progress.to_s) } end diff --git a/service/test/agama/dbus/clients/software_test.rb b/service/test/agama/dbus/clients/software_test.rb index c050e71ce7..c379920f5e 100644 --- a/service/test/agama/dbus/clients/software_test.rb +++ b/service/test/agama/dbus/clients/software_test.rb @@ -34,27 +34,32 @@ allow(bus).to receive(:service).with("org.opensuse.Agama.Software1").and_return(service) allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1") .and_return(dbus_object) - allow(dbus_object).to receive(:introspect) + allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Product") + .and_return(dbus_product) + allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal") + .and_return(dbus_proposal) allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Software1") .and_return(software_iface) - allow(dbus_object).to receive(:[]).with("org.freedesktop.DBus.Properties") + allow(dbus_product).to receive(:[]).with("org.opensuse.Agama.Software1.Product") + .and_return(product_iface) + allow(dbus_product).to receive(:[]).with("org.freedesktop.DBus.Properties") .and_return(properties_iface) - allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal") - .and_return(dbus_proposal) end let(:bus) { instance_double(Agama::DBus::Bus) } let(:service) { instance_double(::DBus::ProxyService) } - let(:dbus_object) { instance_double(::DBus::ProxyObject) } + let(:dbus_object) { instance_double(::DBus::ProxyObject, introspect: nil) } + let(:dbus_product) { instance_double(::DBus::ProxyObject, introspect: nil) } let(:dbus_proposal) { instance_double(::DBus::ProxyObject, introspect: nil) } let(:software_iface) { instance_double(::DBus::ProxyObjectInterface) } let(:properties_iface) { instance_double(::DBus::ProxyObjectInterface) } + let(:product_iface) { instance_double(::DBus::ProxyObjectInterface) } subject { described_class.new } describe "#available_products" do before do - allow(software_iface).to receive(:[]).with("AvailableProducts").and_return( + allow(product_iface).to receive(:[]).with("AvailableProducts").and_return( [ ["Tumbleweed", "openSUSE Tumbleweed", {}], ["Leap15.3", "openSUSE Leap 15.3", {}] @@ -72,7 +77,7 @@ describe "#selected_product" do before do - allow(software_iface).to receive(:[]).with("SelectedProduct").and_return(product) + allow(product_iface).to receive(:[]).with("SelectedProduct").and_return(product) end context "when there is no selected product" do @@ -94,17 +99,17 @@ describe "#select_product" do # Using partial double because methods are dynamically added to the proxy object - let(:dbus_object) { double(::DBus::ProxyObject) } + let(:dbus_product) { double(::DBus::ProxyObject, introspect: nil) } it "selects the given product" do - expect(dbus_object).to receive(:SelectProduct).with("Tumbleweed") + expect(dbus_product).to receive(:SelectProduct).with("Tumbleweed") subject.select_product("Tumbleweed") end end describe "#probe" do - let(:dbus_object) { double(::DBus::ProxyObject, Probe: nil) } + let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil, Probe: nil) } it "calls the D-Bus Probe method" do expect(dbus_object).to receive(:Probe) @@ -125,7 +130,7 @@ end describe "#provisions_selected" do - let(:dbus_object) { double(::DBus::ProxyObject) } + let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil) } it "returns true/false for every tag given" do expect(dbus_object).to receive(:ProvisionsSelected) @@ -136,7 +141,10 @@ end describe "#package_installed?" do - let(:dbus_object) { double(::DBus::ProxyObject, IsPackageInstalled: installed?) } + let(:dbus_object) do + double(::DBus::ProxyObject, introspect: nil, IsPackageInstalled: installed?) + end + let(:package) { "NetworkManager" } context "when the package is installed" do @@ -158,7 +166,7 @@ describe "#on_product_selected" do before do - allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test") + allow(dbus_product).to receive(:path).and_return("/org/opensuse/Agama/Test") allow(properties_iface).to receive(:on_signal) end diff --git a/service/test/agama/dbus/clients/with_issues_examples.rb b/service/test/agama/dbus/clients/with_issues_examples.rb index e599db742f..d409970358 100644 --- a/service/test/agama/dbus/clients/with_issues_examples.rb +++ b/service/test/agama/dbus/clients/with_issues_examples.rb @@ -24,19 +24,54 @@ shared_examples "issues" do before do - allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test") - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Issues") - .and_return(issues_properties) + allow(service).to receive(:root).and_return(root_node) + + allow(dbus_object1).to receive(:[]).with("org.opensuse.Agama1.Issues") + .and_return(issues_interface1) + + allow(dbus_object3).to receive(:[]).with("org.opensuse.Agama1.Issues") + .and_return(issues_interface3) + + allow(issues_interface1).to receive(:[]).with("All").and_return(issues1) + allow(issues_interface3).to receive(:[]).with("All").and_return(issues3) + end + + let(:root_node) do + instance_double(::DBus::Node, descendant_objects: [dbus_object1, dbus_object2, dbus_object3]) + end + + let(:dbus_object1) do + instance_double(::DBus::ProxyObject, + interfaces: ["org.opensuse.Agama1.Test", "org.opensuse.Agama1.Issues"]) + end + + let(:dbus_object2) do + instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Test"]) + end + + let(:dbus_object3) do + instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Issues"]) end - let(:issues_properties) { { "All" => issues } } + let(:issues_interface1) { instance_double(::DBus::ProxyObjectInterface) } + + let(:issues_interface3) { instance_double(::DBus::ProxyObjectInterface) } - let(:issues) { [issue1, issue2] } - let(:issue1) { ["Issue 1", "Details 1", 1, 0] } - let(:issue2) { ["Issue 2", "Details 2", 2, 1] } + let(:issues1) do + [ + ["Issue 1", "Details 1", 1, 0], + ["Issue 2", "Details 2", 2, 1] + ] + end + + let(:issues3) do + [ + ["Issue 3", "Details 3", 1, 0] + ] + end describe "#issues" do - it "returns the list of issues" do + it "returns the list of issues from all objects" do expect(subject.issues).to all(be_a(Agama::Issue)) expect(subject.issues).to contain_exactly( @@ -51,6 +86,12 @@ details: "Details 2", source: Agama::Issue::Source::CONFIG, severity: Agama::Issue::Severity::ERROR + ), + an_object_having_attributes( + description: "Issue 3", + details: "Details 3", + source: Agama::Issue::Source::SYSTEM, + severity: Agama::Issue::Severity::WARN ) ) end @@ -58,15 +99,13 @@ describe "#errors?" do context "if there is any error" do - let(:issues) { [issue2] } - it "returns true" do expect(subject.errors?).to eq(true) end end context "if there is no error" do - let(:issues) { [issue1] } + let(:issues1) { [] } it "returns false" do expect(subject.errors?).to eq(false) diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index a8f6d04e8d..edc0ae1946 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -87,41 +87,6 @@ backend.issues = [] end - describe "select_product" do - context "if the product is correctly selected" do - it "returns result code 0 with empty description" do - expect(subject.select_product("Tumbleweed")).to contain_exactly(0, "") - end - end - - context "if the given product is already selected" do - before do - subject.select_product("Tumbleweed") - end - - it "returns result code 1 and description" do - expect(subject.select_product("Tumbleweed")).to contain_exactly(1, /already selected/) - end - end - - context "if the current product is registered" do - before do - subject.select_product("Leap") - allow(backend.registration).to receive(:reg_code).and_return("123XX432") - end - - it "returns result code 2 and description" do - expect(subject.select_product("Tumbleweed")).to contain_exactly(2, /must be deregistered/) - end - end - - context "if the product is unknown" do - it "returns result code 3 and description" do - expect(subject.select_product("Unknown")).to contain_exactly(3, /unknown product/i) - end - end - end - describe "#probe" do it "runs the probing, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) @@ -171,298 +136,4 @@ expect(installed).to eq(true) end end - - describe "#reg_code" do - before do - allow(backend.registration).to receive(:reg_code).and_return(reg_code) - end - - context "if there is no registered product yet" do - let(:reg_code) { nil } - - it "returns an empty string" do - expect(subject.reg_code).to eq("") - end - end - - context "if there is a registered product" do - let(:reg_code) { "123XX432" } - - it "returns the registration code" do - expect(subject.reg_code).to eq("123XX432") - end - end - end - - describe "#email" do - before do - allow(backend.registration).to receive(:email).and_return(email) - end - - context "if there is no registered email" do - let(:email) { nil } - - it "returns an empty string" do - expect(subject.email).to eq("") - end - end - - context "if there is a registered email" do - let(:email) { "test@suse.com" } - - it "returns the registered email" do - expect(subject.email).to eq("test@suse.com") - end - end - end - - describe "#requirement" do - before do - allow(backend.registration).to receive(:requirement).and_return(requirement) - end - - context "if the registration is not required" do - let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED } - - it "returns 0" do - expect(subject.requirement).to eq(0) - end - end - - context "if the registration is optional" do - let(:requirement) { Agama::Registration::Requirement::OPTIONAL } - - it "returns 1" do - expect(subject.requirement).to eq(1) - end - end - - context "if the registration is mandatory" do - let(:requirement) { Agama::Registration::Requirement::MANDATORY } - - it "returns 2" do - expect(subject.requirement).to eq(2) - end - end - end - - describe "#register" do - before do - allow(backend.registration).to receive(:reg_code).and_return(nil) - end - - context "if there is no product selected yet" do - it "returns result code 1 and description" do - expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i) - end - end - - context "if there is a selected product" do - before do - backend.select_product("Tumbleweed") - end - - context "if the product is already registered" do - before do - allow(backend.registration).to receive(:reg_code).and_return("123XX432") - end - - it "returns result code 2 and description" do - expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) - end - end - - context "if there is a network error" do - before do - allow(backend.registration).to receive(:register).and_raise(SocketError) - end - - it "returns result code 3 and description" do - expect(subject.register("123XX432")).to contain_exactly(3, /network error/) - end - end - - context "if there is a timeout" do - before do - allow(backend.registration).to receive(:register).and_raise(Timeout::Error) - end - - it "returns result code 4 and description" do - expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) - end - end - - context "if there is an API error" do - before do - allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") - end - - it "returns result code 5 and description" do - expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) - end - end - - context "if there is a missing credials error" do - before do - allow(backend.registration) - .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) - end - - it "returns result code 6 and description" do - expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) - end - end - - context "if there is an incorrect credials error" do - before do - allow(backend.registration) - .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) - end - - it "returns result code 7 and description" do - expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) - end - end - - context "if there is an invalid certificate error" do - before do - allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) - end - - it "returns result code 8 and description" do - expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) - end - end - - context "if there is an internal error" do - before do - allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) - end - - it "returns result code 9 and description" do - expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) - end - end - - context "if the registration is correctly done" do - before do - allow(backend.registration).to receive(:register) - end - - it "returns result code 0 with empty description" do - expect(subject.register("123XX432")).to contain_exactly(0, "") - end - end - end - end - - describe "#deregister" do - before do - allow(backend.registration).to receive(:reg_code).and_return("123XX432") - end - - context "if there is no product selected yet" do - it "returns result code 1 and description" do - expect(subject.deregister).to contain_exactly(1, /product not selected/i) - end - end - - context "if there is a selected product" do - before do - backend.select_product("Tumbleweed") - end - - context "if the product is not registered yet" do - before do - allow(backend.registration).to receive(:reg_code).and_return(nil) - end - - it "returns result code 2 and description" do - expect(subject.deregister).to contain_exactly(2, /product not registered/i) - end - end - - context "if there is a network error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(SocketError) - end - - it "returns result code 3 and description" do - expect(subject.deregister).to contain_exactly(3, /network error/) - end - end - - context "if there is a timeout" do - before do - allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) - end - - it "returns result code 4 and description" do - expect(subject.deregister).to contain_exactly(4, /timeout/) - end - end - - context "if there is an API error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") - end - - it "returns result code 5 and description" do - expect(subject.deregister).to contain_exactly(5, /registration server failed/) - end - end - - context "if there is a missing credials error" do - before do - allow(backend.registration) - .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) - end - - it "returns result code 6 and description" do - expect(subject.deregister).to contain_exactly(6, /missing credentials/) - end - end - - context "if there is an incorrect credials error" do - before do - allow(backend.registration) - .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) - end - - it "returns result code 7 and description" do - expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) - end - end - - context "if there is an invalid certificate error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) - end - - it "returns result code 8 and description" do - expect(subject.deregister).to contain_exactly(8, /invalid certificate/) - end - end - - context "if there is an internal error" do - before do - allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) - end - - it "returns result code 9 and description" do - expect(subject.deregister).to contain_exactly(9, /registration server failed/) - end - end - - context "if the deregistration is correctly done" do - before do - allow(backend.registration).to receive(:deregister) - end - - it "returns result code 0 with empty description" do - expect(subject.deregister).to contain_exactly(0, "") - end - end - end - end end diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb new file mode 100644 index 0000000000..475b2b2cc0 --- /dev/null +++ b/service/test/agama/dbus/software/product_test.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "agama/dbus/software/product" +require "agama/config" +require "agama/registration" +require "agama/software/manager" +require "suse/connect" + +describe Agama::DBus::Software::Product do + subject { described_class.new(backend, logger) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + let(:backend) { Agama::Software::Manager.new(config, logger) } + + let(:config) do + Agama::Config.new(YAML.safe_load(File.read(config_path))) + end + + let(:config_path) do + File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") + end + + before do + allow(subject).to receive(:dbus_properties_changed) + end + + it "defines Product D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama.Software1.Product") + end + + it "defines Registration D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama1.Registration") + end + + it "defines Issues D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama1.Issues") + end + + describe "select_product" do + context "if the product is correctly selected" do + it "returns result code 0 with empty description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(0, "") + end + end + + context "if the given product is already selected" do + before do + subject.select_product("Tumbleweed") + end + + it "returns result code 1 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(1, /already selected/) + end + end + + context "if the current product is registered" do + before do + subject.select_product("Leap") + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(2, /must be deregistered/) + end + end + + context "if the product is unknown" do + it "returns result code 3 and description" do + expect(subject.select_product("Unknown")).to contain_exactly(3, /unknown product/i) + end + end + end + + describe "#reg_code" do + before do + allow(backend.registration).to receive(:reg_code).and_return(reg_code) + end + + context "if there is no registered product yet" do + let(:reg_code) { nil } + + it "returns an empty string" do + expect(subject.reg_code).to eq("") + end + end + + context "if there is a registered product" do + let(:reg_code) { "123XX432" } + + it "returns the registration code" do + expect(subject.reg_code).to eq("123XX432") + end + end + end + + describe "#email" do + before do + allow(backend.registration).to receive(:email).and_return(email) + end + + context "if there is no registered email" do + let(:email) { nil } + + it "returns an empty string" do + expect(subject.email).to eq("") + end + end + + context "if there is a registered email" do + let(:email) { "test@suse.com" } + + it "returns the registered email" do + expect(subject.email).to eq("test@suse.com") + end + end + end + + describe "#requirement" do + before do + allow(backend.registration).to receive(:requirement).and_return(requirement) + end + + context "if the registration is not required" do + let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED } + + it "returns 0" do + expect(subject.requirement).to eq(0) + end + end + + context "if the registration is optional" do + let(:requirement) { Agama::Registration::Requirement::OPTIONAL } + + it "returns 1" do + expect(subject.requirement).to eq(1) + end + end + + context "if the registration is mandatory" do + let(:requirement) { Agama::Registration::Requirement::MANDATORY } + + it "returns 2" do + expect(subject.requirement).to eq(2) + end + end + end + + describe "#register" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + context "if there is no product selected yet" do + it "returns result code 1 and description" do + expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i) + end + end + + context "if there is a selected product" do + before do + backend.select_product("Tumbleweed") + end + + context "if the product is already registered" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:register).and_raise(SocketError) + end + + it "returns result code 3 and description" do + expect(subject.register("123XX432")).to contain_exactly(3, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:register).and_raise(Timeout::Error) + end + + it "returns result code 4 and description" do + expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 5 and description" do + expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 6 and description" do + expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 8 and description" do + expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) + end + + it "returns result code 9 and description" do + expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) + end + end + + context "if the registration is correctly done" do + before do + allow(backend.registration).to receive(:register) + end + + it "returns result code 0 with empty description" do + expect(subject.register("123XX432")).to contain_exactly(0, "") + end + end + end + end + + describe "#deregister" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + context "if there is no product selected yet" do + it "returns result code 1 and description" do + expect(subject.deregister).to contain_exactly(1, /product not selected/i) + end + end + + context "if there is a selected product" do + before do + backend.select_product("Tumbleweed") + end + + context "if the product is not registered yet" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + it "returns result code 2 and description" do + expect(subject.deregister).to contain_exactly(2, /product not registered/i) + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SocketError) + end + + it "returns result code 3 and description" do + expect(subject.deregister).to contain_exactly(3, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) + end + + it "returns result code 4 and description" do + expect(subject.deregister).to contain_exactly(4, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 5 and description" do + expect(subject.deregister).to contain_exactly(5, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 6 and description" do + expect(subject.deregister).to contain_exactly(6, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 8 and description" do + expect(subject.deregister).to contain_exactly(8, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) + end + + it "returns result code 9 and description" do + expect(subject.deregister).to contain_exactly(9, /registration server failed/) + end + end + + context "if the deregistration is correctly done" do + before do + allow(backend.registration).to receive(:deregister) + end + + it "returns result code 0 with empty description" do + expect(subject.deregister).to contain_exactly(0, "") + end + end + end + end +end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 984d0d89b3..73b05d3031 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -111,50 +111,18 @@ expect(manager.product).to be_nil end - - it "adds a not selected product issue" do - manager = described_class.new(config, logger) - - expect(manager.issues).to contain_exactly(an_object_having_attributes( - description: /product not selected/i - )) - end end context "if there is only a product" do let(:products) { [product] } - let(:product) do - Agama::Software::Product.new("test1").tap { |p| p.repositories = product_repositories } - end - - let(:product_repositories) { [] } + let(:product) { Agama::Software::Product.new("test1") } it "selects the product" do manager = described_class.new(config, logger) expect(manager.product.id).to eq("test1") end - - context "if the product requires registration" do - let(:product_repositories) { [] } - - it "adds a registration issue" do - manager = described_class.new(config, logger) - - expect(manager.issues).to include(an_object_having_attributes( - description: /product must be registered/i - )) - end - - context "if the product does not require registration" do - let(:product_repositories) { ["https://test"] } - - it "does not add issues" do - expect(subject.issues).to be_empty - end - end - end end end @@ -212,38 +180,6 @@ )) end end - - context "if the product is not registered" do - let(:reg_code) { nil } - - before do - allow(subject.registration).to receive(:requirement).and_return(reg_requirement) - end - - context "and registration is mandatory" do - let(:reg_requirement) { Agama::Registration::Requirement::MANDATORY } - - it "adds registration issue" do - subject.public_send(tested_method) - - expect(subject.issues).to include(an_object_having_attributes( - description: /product must be registered/i - )) - end - end - - context "and registration is not mandatory" do - let(:reg_requirement) { Agama::Registration::Requirement::OPTIONAL } - - it "does not add registration issue" do - subject.public_send(tested_method) - - expect(subject.issues).to_not include(an_object_having_attributes( - description: /product must be registered/i - )) - end - end - end end describe "#probe" do @@ -399,6 +335,83 @@ end end + describe "#product_issues" do + before do + allow_any_instance_of(Agama::Software::ProductBuilder) + .to receive(:build).and_return([product1, product2]) + end + + let(:product1) do + Agama::Software::Product.new("test1").tap { |p| p.repositories = [] } + end + + let(:product2) do + Agama::Software::Product.new("test2").tap { |p| p.repositories = ["http://test"] } + end + + context "if no product is selected yet" do + it "contains a missing product issue" do + expect(subject.product_issues).to contain_exactly( + an_object_having_attributes( + description: /product not selected/i + ) + ) + end + end + + context "if a product is already selected" do + before do + subject.select_product(product_id) + end + + let(:product_id) { "test1" } + + it "does not include a missing product issue" do + expect(subject.product_issues).to_not include( + an_object_having_attributes( + description: /product not selected/i + ) + ) + end + + context "and the product does not require registration" do + let(:product_id) { "test2" } + + it "does not contain issues" do + expect(subject.product_issues).to be_empty + end + end + + context "and the product requires registration" do + let(:product_id) { "test1" } + + before do + allow(subject.registration).to receive(:reg_code).and_return(reg_code) + end + + context "and the product is not registered" do + let(:reg_code) { nil } + + it "contains a missing registration issue" do + expect(subject.product_issues).to contain_exactly( + an_object_having_attributes( + description: /product must be registered/i + ) + ) + end + end + + context "and the product is registered" do + let(:reg_code) { "1234XX5678" } + + it "does not contain issues" do + expect(subject.product_issues).to be_empty + end + end + end + end + end + include_examples "issues" include_examples "progress" end From 5c3809a803402d17a7189b151de5f1fec96e121a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 20 Oct 2023 14:04:25 +0200 Subject: [PATCH 55/97] copy credentials in installation --- service/lib/agama/registration.rb | 10 ++++++++++ service/lib/agama/software/manager.rb | 1 + 2 files changed, 11 insertions(+) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 64c64bcecc..e19ed98e78 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -126,6 +126,16 @@ def deregister run_on_change_callbacks end + def finish + return unless reg_code + + files = [@credentials_file, SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] + files.each do |file| + dest = File.join(Yast::Installation.destdir, file) + FileUtils::cp(file, dest) + end + end + # Indicates whether the registration is optional, mandatory or not required. # # @return [Symbol] See {Requirement}. diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 6f819f99dc..94bb65beb9 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -183,6 +183,7 @@ def finish Yast::Pkg.SourceSaveAll Yast::Pkg.TargetFinish Yast::Pkg.SourceCacheCopyTo(Yast::Installation.destdir) + registration.finish end progress.step("Restoring original repositories") { restore_original_repos } end From 0b21ca7e5f70a47f4eb6c5a5507b3a2b4b5338a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 15:10:44 +0100 Subject: [PATCH 56/97] [service] Registration tests and minor fixes --- service/lib/agama/registration.rb | 6 +- .../test/agama/dbus/software/manager_test.rb | 9 +- .../test/agama/dbus/software/product_test.rb | 9 +- service/test/agama/registration_test.rb | 375 ++++++++++++++++++ service/test/agama/software/manager_test.rb | 4 +- 5 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 service/test/agama/registration_test.rb diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index e19ed98e78..093d95db13 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -64,7 +64,7 @@ def initialize(software_manager, logger) # @param code [String] Registration code. # @param email [String] Email for registering the product. def register(code, email: "") - return unless product + return if product.nil? || reg_code connect_params = { token: code, @@ -79,7 +79,7 @@ def register(code, email: "") target_product = OpenStruct.new( arch: Yast::Arch.rpm_arch, identifier: product.id, - version: product.version + version: product.version || "1.0" ) activate_params = {} @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) @@ -106,6 +106,8 @@ def register(code, email: "") # OpenSSL::SSL::SSLError|JSON::ParserError # ] def deregister + return unless product && reg_code + Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) @software.remove_service(@service) diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index edc0ae1946..925218b9d5 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -36,12 +36,11 @@ let(:backend) { Agama::Software::Manager.new(config, logger) } - let(:config) do - Agama::Config.new(YAML.safe_load(File.read(config_path))) - end + let(:config) { Agama::Config.new(config_data) } - let(:config_path) do - File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") + let(:config_data) do + path = File.join(FIXTURES_PATH, "root_dir/etc/agama.yaml") + YAML.safe_load(File.read(path)) end let(:progress_interface) { Agama::DBus::Interfaces::Progress::PROGRESS_INTERFACE } diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb index 475b2b2cc0..b7b89a875d 100644 --- a/service/test/agama/dbus/software/product_test.rb +++ b/service/test/agama/dbus/software/product_test.rb @@ -33,12 +33,11 @@ let(:backend) { Agama::Software::Manager.new(config, logger) } - let(:config) do - Agama::Config.new(YAML.safe_load(File.read(config_path))) - end + let(:config) { Agama::Config.new(config_data) } - let(:config_path) do - File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") + let(:config_data) do + path = File.join(FIXTURES_PATH, "root_dir/etc/agama.yaml") + YAML.safe_load(File.read(path)) end before do diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb new file mode 100644 index 0000000000..a2dd6731b8 --- /dev/null +++ b/service/test/agama/registration_test.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../test_helper" +require "agama/config" +require "agama/registration" +require "agama/software/manager" +require "suse/connect" +require "yast" +require "y2packager/new_repository_setup" + +Yast.import("Arch") + +describe Agama::Registration do + subject { described_class.new(manager, logger) } + + let(:manager) { instance_double(Agama::Software::Manager) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + allow(Yast::Arch).to receive(:rpm_arch).and_return("x86_64") + + allow(manager).to receive(:product).and_return(product) + allow(manager).to receive(:add_service) + allow(manager).to receive(:remove_service) + + allow(SUSE::Connect::YaST).to receive(:announce_system).and_return(["test-user", "12345"]) + allow(SUSE::Connect::YaST).to receive(:deactivate_system) + allow(SUSE::Connect::YaST).to receive(:create_credentials_file) + allow(SUSE::Connect::YaST).to receive(:activate_product).and_return(service) + allow(Y2Packager::NewRepositorySetup.instance).to receive(:add_service) + end + + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + describe "#register" do + context "if there is no product selected yet" do + let(:product) { nil } + + it "does not try to register" do + expect(SUSE::Connect::YaST).to_not receive(:announce_system) + + subject.register("11112222", email: "test@test.com") + end + end + + context "if there is a selected product" do + let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } } + + context "and the product is already registered" do + before do + subject.register("11112222", email: "test@test.com") + end + + it "does not try to register" do + expect(SUSE::Connect::YaST).to_not receive(:announce_system) + + subject.register("11112222", email: "test@test.com") + end + end + + context "and the product is not registered yet" do + it "announces the system" do + expect(SUSE::Connect::YaST).to receive(:announce_system).with( + { token: "11112222", email: "test@test.com" }, + "test-5-x86_64" + ) + + subject.register("11112222", email: "test@test.com") + end + + it "creates credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + .with("test-user", "12345") + + subject.register("11112222", email: "test@test.com") + end + + it "activates the selected product" do + expect(SUSE::Connect::YaST).to receive(:activate_product).with( + an_object_having_attributes( + arch: "x86_64", identifier: "test", version: "5.0" + ), {}, "test@test.com" + ) + + subject.register("11112222", email: "test@test.com") + end + + it "adds the service to software manager" do + expect(Y2Packager::NewRepositorySetup.instance) + .to receive(:add_service).with("test-service") + + subject.register("11112222", email: "test@test.com") + end + + context "if the service requires a creadentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") } + + before do + allow(subject).to receive(:credentials_from_url) + .with("https://credentials/file").and_return("credentials") + end + + it "creates the credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + .with("test-user", "12345", "credentials") + + subject.register("11112222", email: "test@test.com") + end + end + + context "if the service does not require a creadentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + it "does not create the credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + expect(SUSE::Connect::YaST).to_not receive(:create_credentials_file) + .with("test-user", "12345", anything) + + subject.register("11112222", email: "test@test.com") + end + end + + context "if the product was correctly registered" do + before do + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "runs the callbacks" do + expect(callback).to receive(:call) + + subject.register("11112222", email: "test@test.com") + end + + it "sets the registration code" do + subject.register("11112222", email: "test@test.com") + + expect(subject.reg_code).to eq("11112222") + end + + it "sets the email" do + subject.register("11112222", email: "test@test.com") + + expect(subject.email).to eq("test@test.com") + end + end + + context "if the product was not correctly registered" do + before do + allow(SUSE::Connect::YaST).to receive(:activate_product).and_raise(Timeout::Error) + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "raises an error" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + end + + it "does not run the callbacks" do + expect(callback).to_not receive(:call) + + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + end + + it "does not set the registration code" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + + expect(subject.reg_code).to be_nil + end + + it "does not set the email" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + + expect(subject.email).to be_nil + end + end + end + end + end + + describe "#deregister" do + before do + allow(FileUtils).to receive(:rm) + end + + context "if there is no product selected yet" do + let(:product) { nil } + + it "does not try to deregister" do + expect(SUSE::Connect::YaST).to_not receive(:deactivate_system) + + subject.deregister + end + end + + context "if there is a selected product" do + let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } } + + context "and the product is not registered yet" do + it "does not try to deregister" do + expect(SUSE::Connect::YaST).to_not receive(:deactivate_system) + + subject.deregister + end + end + + context "and the product is registered" do + before do + allow(subject).to receive(:credentials_from_url) + allow(subject).to receive(:credentials_from_url) + .with("https://credentials/file").and_return("credentials") + + subject.register("11112222", email: "test@test.com") + end + + it "deletes the service from the software config" do + expect(manager).to receive(:remove_service).with(service) + + subject.deregister + end + + it "deactivates the system" do + expect(SUSE::Connect::YaST).to receive(:deactivate_system).with( + { token: "11112222", email: "test@test.com" } + ) + + subject.deregister + end + + it "removes the credentials file" do + expect(FileUtils).to receive(:rm).with(/SCCcredentials/) + + subject.deregister + end + + context "if the service has a credentials files" do + let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") } + + it "removes the credentials file" do + expect(FileUtils).to receive(:rm) + expect(FileUtils).to receive(:rm).with(/\/credentials$/) + + subject.deregister + end + end + + context "if the product has no credentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + it "does not try to remove the credentials file" do + expect(FileUtils).to_not receive(:rm).with(/\/credentials$/) + + subject.deregister + end + end + + context "if the product was correctly deregistered" do + before do + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "runs the callbacks" do + expect(callback).to receive(:call) + + subject.deregister + end + + it "removes the registration code" do + subject.deregister + + expect(subject.reg_code).to be_nil + end + + it "removes the email" do + subject.deregister + + expect(subject.email).to be_nil + end + end + + context "if the product was not correctly deregistered" do + before do + allow(SUSE::Connect::YaST).to receive(:deactivate_system).and_raise(Timeout::Error) + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "raises an error" do + expect { subject.deregister }.to raise_error(Timeout::Error) + end + + it "does not run the callbacks" do + expect(callback).to_not receive(:call) + + expect { subject.deregister }.to raise_error(Timeout::Error) + end + + it "does not remove the registration code" do + expect { subject.deregister }.to raise_error(Timeout::Error) + + expect(subject.reg_code).to eq("11112222") + end + + it "does not remove the email" do + expect { subject.deregister }.to raise_error(Timeout::Error) + + expect(subject.email).to eq("test@test.com") + end + end + end + end + end + + describe "#requirement" do + context "if there is not product selected yet" do + let(:product) { nil } + + it "returns not required" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + end + end + + context "if there is a selected product" do + let(:product) do + Agama::Software::Product.new("test").tap { |p| p.repositories = repositories } + end + + context "and the product has repositories" do + let(:repositories) { ["https://repo"] } + + it "returns not required" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + end + end + + context "and the product has no repositories" do + let(:repositories) { [] } + + it "returns mandatory" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::MANDATORY) + end + end + end + end +end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 73b05d3031..9760358c73 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -22,9 +22,7 @@ require_relative "../../test_helper" require_relative "../with_issues_examples" require_relative "../with_progress_examples" -require_relative File.join( - SRC_PATH, "agama", "dbus", "y2dir", "software", "modules", "PackageCallbacks.rb" -) +require_relative File.join(SRC_PATH, "agama/dbus/y2dir/software/modules/PackageCallbacks.rb") require "agama/config" require "agama/issue" require "agama/registration" From 08144604cba2798489bb3eab880858a85bb4233f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 15:29:22 +0100 Subject: [PATCH 57/97] [service] Add missing check --- service/lib/agama/dbus/software/product.rb | 19 +++++---- service/lib/agama/registration.rb | 5 ++- .../test/agama/dbus/software/product_test.rb | 40 ++++++++++++------- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 475107f75b..83a4c4a703 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -150,20 +150,23 @@ def requirement # 0: success # 1: missing product # 2: already registered - # 3: network error - # 4: timeout error - # 5: api error - # 6: missing credentials - # 7: incorrect credentials - # 8: invalid certificate - # 9: internal error (e.g., parsing json data) + # 3: registration not required + # 4: network error + # 5: timeout error + # 6: api error + # 7: missing credentials + # 8: incorrect credentials + # 9: invalid certificate + # 10: internal error (e.g., parsing json data) def register(reg_code, email: nil) if !backend.product [1, "Product not selected yet"] elsif backend.registration.reg_code [2, "Product already registered"] + elsif backend.registration.requirement == Agama::Registration::Requirement::NOT_REQUIRED + [3, "Product does not require registration"] else - connect_result(first_error_code: 3) do + connect_result(first_error_code: 4) do backend.registration.register(reg_code, email: email) end end diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 093d95db13..56944cedbf 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -106,7 +106,7 @@ def register(code, email: "") # OpenSSL::SSL::SSLError|JSON::ParserError # ] def deregister - return unless product && reg_code + return unless reg_code Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) @software.remove_service(@service) @@ -128,13 +128,14 @@ def deregister run_on_change_callbacks end + # Copies credentials files to the target system. def finish return unless reg_code files = [@credentials_file, SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] files.each do |file| dest = File.join(Yast::Installation.destdir, file) - FileUtils::cp(file, dest) + FileUtils.cp(file, dest) end end diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb index b7b89a875d..57f2e442f8 100644 --- a/service/test/agama/dbus/software/product_test.rb +++ b/service/test/agama/dbus/software/product_test.rb @@ -179,8 +179,12 @@ context "if there is a selected product" do before do backend.select_product("Tumbleweed") + + allow(backend.product).to receive(:repositories).and_return(repositories) end + let(:repositories) { [] } + context "if the product is already registered" do before do allow(backend.registration).to receive(:reg_code).and_return("123XX432") @@ -191,13 +195,21 @@ end end + context "if the product does not require registration" do + let(:repositories) { ["https://repo"] } + + it "returns result code 3 and description" do + expect(subject.register("123XX432")).to contain_exactly(3, /not require registration/i) + end + end + context "if there is a network error" do before do allow(backend.registration).to receive(:register).and_raise(SocketError) end - it "returns result code 3 and description" do - expect(subject.register("123XX432")).to contain_exactly(3, /network error/) + it "returns result code 4 and description" do + expect(subject.register("123XX432")).to contain_exactly(4, /network error/) end end @@ -206,8 +218,8 @@ allow(backend.registration).to receive(:register).and_raise(Timeout::Error) end - it "returns result code 4 and description" do - expect(subject.register("123XX432")).to contain_exactly(4, /timeout/) + it "returns result code 5 and description" do + expect(subject.register("123XX432")).to contain_exactly(5, /timeout/) end end @@ -216,8 +228,8 @@ allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") end - it "returns result code 5 and description" do - expect(subject.register("123XX432")).to contain_exactly(5, /registration server failed/) + it "returns result code 6 and description" do + expect(subject.register("123XX432")).to contain_exactly(6, /registration server failed/) end end @@ -227,8 +239,8 @@ .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) end - it "returns result code 6 and description" do - expect(subject.register("123XX432")).to contain_exactly(6, /missing credentials/) + it "returns result code 7 and description" do + expect(subject.register("123XX432")).to contain_exactly(7, /missing credentials/) end end @@ -238,8 +250,8 @@ .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) end - it "returns result code 7 and description" do - expect(subject.register("123XX432")).to contain_exactly(7, /incorrect credentials/) + it "returns result code 8 and description" do + expect(subject.register("123XX432")).to contain_exactly(8, /incorrect credentials/) end end @@ -248,8 +260,8 @@ allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) end - it "returns result code 8 and description" do - expect(subject.register("123XX432")).to contain_exactly(8, /invalid certificate/) + it "returns result code 9 and description" do + expect(subject.register("123XX432")).to contain_exactly(9, /invalid certificate/) end end @@ -258,8 +270,8 @@ allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) end - it "returns result code 9 and description" do - expect(subject.register("123XX432")).to contain_exactly(9, /registration server failed/) + it "returns result code 10 and description" do + expect(subject.register("123XX432")).to contain_exactly(10, /registration server failed/) end end From 691f71c26ebd5ecd48d20927719724bf75db8f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 15:38:00 +0100 Subject: [PATCH 58/97] [service] Use ruby-dbus 0.23.1 --- service/Gemfile.lock | 4 ++-- service/agama.gemspec | 2 +- service/lib/agama/dbus/software/manager.rb | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/service/Gemfile.lock b/service/Gemfile.lock index c114df5bb3..89fdf2541e 100644 --- a/service/Gemfile.lock +++ b/service/Gemfile.lock @@ -9,7 +9,7 @@ PATH fast_gettext (~> 2.2.0) nokogiri (~> 1.13.1) rexml (~> 3.2.5) - ruby-dbus (>= 0.23.0.beta2, < 1.0) + ruby-dbus (>= 0.23.1, < 1.0) GEM remote: https://rubygems.org/ @@ -49,7 +49,7 @@ GEM rspec-support (~> 3.11.0) rspec-support (3.11.0) ruby-augeas (0.5.0) - ruby-dbus (0.23.0.beta2) + ruby-dbus (0.23.1) rexml simplecov (0.21.2) docile (~> 1.1) diff --git a/service/agama.gemspec b/service/agama.gemspec index bb2b561d4a..97df966e22 100644 --- a/service/agama.gemspec +++ b/service/agama.gemspec @@ -58,5 +58,5 @@ Gem::Specification.new do |spec| spec.add_dependency "fast_gettext", "~> 2.2.0" spec.add_dependency "nokogiri", "~> 1.13.1" spec.add_dependency "rexml", "~> 3.2.5" - spec.add_dependency "ruby-dbus", ">= 0.23.0.beta2", "< 1.0" + spec.add_dependency "ruby-dbus", ">= 0.23.1", "< 1.0" end diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 06df099ee3..aad196e3cb 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -83,12 +83,9 @@ def issues ] end - # documented way to be able to write to patterns and trigger signal - attr_writer :selected_patterns - # selected patterns is hash with pattern name as id and 0 for user selected and # 1 for auto selected. Can be extended in future e.g. for mandatory patterns - dbus_attr_reader :selected_patterns, "a{sy}" + dbus_reader_attr_accessor :selected_patterns, "a{sy}" dbus_method(:AddPattern, "in id:s") { |p| backend.add_pattern(p) } dbus_method(:RemovePattern, "in id:s") { |p| backend.remove_pattern(p) } From a2c702b9c4e3562e2b3f43a58ba74c71c6274ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 16:08:28 +0100 Subject: [PATCH 59/97] [service] Update D-Bus documentation --- ...g.opensuse.Agama.Software1.Product.bus.xml | 53 +++++++++++++++++++ .../bus/org.opensuse.Agama.Software1.bus.xml | 20 +------ .../org.opensuse.Agama1.Registration.bus.xml | 2 +- ...g.opensuse.Agama.Software1.Product.doc.xml | 38 +++++++++++++ doc/dbus/org.opensuse.Agama.Software1.doc.xml | 34 +----------- doc/dbus/org.opensuse.Agama1.Progress.doc.xml | 4 +- .../org.opensuse.Agama1.Registration.doc.xml | 17 +++--- 7 files changed, 105 insertions(+), 63 deletions(-) create mode 100644 doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml create mode 100644 doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml new file mode 100644 index 0000000000..7bd3ab733f --- /dev/null +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml index 44b5873a17..9535bb3c0f 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml @@ -1,5 +1,6 @@ + @@ -28,10 +29,6 @@ - - - - @@ -64,8 +61,6 @@ - - @@ -76,19 +71,6 @@ - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml index d0feb248d1..9cfd11ce93 120000 --- a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml @@ -1 +1 @@ -org.opensuse.Agama.Software1.bus.xml \ No newline at end of file +org.opensuse.Agama.Software1.Product.bus.xml \ No newline at end of file diff --git a/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml new file mode 100644 index 0000000000..165a67094b --- /dev/null +++ b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml index 82bc7e5208..bacbe87d94 100644 --- a/doc/dbus/org.opensuse.Agama.Software1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -1,27 +1,9 @@ + - - - - - - - @@ -54,20 +36,6 @@ - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml index 7c2cc909fd..1a1a3ed0e0 100644 --- a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml +++ b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml @@ -1,7 +1,9 @@ - + + + @@ -25,13 +23,14 @@ 0: success 1: missing product 2: already registered - 3: network error - 4: timeout error - 5: api error - 6: missing credentials - 7: incorrect credentials + 3: registration not required + 4: network error + 5: timeout error + 6: api error + 7: missing credentials + 8: incorrect credentials 8: invalid certificate - 9: internal error (e.g., parsing json data) + 10: internal error (e.g., parsing json data) --> From 7317e611d10ad1d35afd9ac017cf636c54fd2b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 20 Oct 2023 16:18:32 +0100 Subject: [PATCH 60/97] [web] Fix tests --- web/src/client/software.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index 5e12a2ff54..65fd0e2a9a 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -26,9 +26,9 @@ import { SoftwareClient } from "./software"; jest.mock("./dbus"); -const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; +const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; -const softProxy = { +const productProxy = { wait: jest.fn(), AvailableProducts: [ ["MicroOS", "openSUSE MicroOS", {}], @@ -42,7 +42,7 @@ beforeEach(() => { DBusClient.mockImplementation(() => { return { proxy: (iface) => { - if (iface === SOFTWARE_IFACE) return softProxy; + if (iface === PRODUCT_IFACE) return productProxy; } }; }); From c99a98e21d97c2577736257bc7e8c6fb62e0cc51 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 20 Oct 2023 17:37:50 +0200 Subject: [PATCH 61/97] run probe and propose after registration/deregistration --- service/lib/agama/software/manager.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 94bb65beb9..d72ffc94cf 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -278,7 +278,14 @@ def used_disk_space end def registration - @registration ||= Registration.new(self, @logger) + return @registration if @registration + + @registration = Registration.new(self, @logger) + @registration.on_change do + # reprobe and repropose when system is register or deregistered + probe + proposal + end end # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 From 133acc889f716fa3a6d90591c2705c0dcf52ca83 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 20 Oct 2023 21:57:38 +0200 Subject: [PATCH 62/97] fix return statement --- service/lib/agama/software/manager.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index d72ffc94cf..c0dea816fe 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -286,6 +286,8 @@ def registration probe proposal end + + @registration end # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 From aa57609ac10057aace093088ffc1983dd567183a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 23 Oct 2023 11:29:59 +0100 Subject: [PATCH 63/97] [rust] Adapt software proxies and client --- rust/agama-lib/src/software/client.rs | 12 ++++++------ rust/agama-lib/src/software/proxies.rs | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 8e90b5a474..2d6ae30c39 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -1,4 +1,4 @@ -use super::proxies::Software1Proxy; +use super::proxies::SoftwareProductProxy; use crate::error::ServiceError; use serde::Serialize; use zbus::Connection; @@ -16,20 +16,20 @@ pub struct Product { /// D-Bus client for the software service pub struct SoftwareClient<'a> { - software_proxy: Software1Proxy<'a>, + product_proxy: SoftwareProductProxy<'a>, } impl<'a> SoftwareClient<'a> { pub async fn new(connection: Connection) -> Result, ServiceError> { Ok(Self { - software_proxy: Software1Proxy::new(&connection).await?, + product_proxy: SoftwareProductProxy::new(&connection).await?, }) } /// Returns the available products pub async fn products(&self) -> Result, ServiceError> { let products: Vec = self - .software_proxy + .product_proxy .available_products() .await? .into_iter() @@ -50,12 +50,12 @@ impl<'a> SoftwareClient<'a> { /// Returns the selected product to install pub async fn product(&self) -> Result { - Ok(self.software_proxy.selected_product().await?) + Ok(self.product_proxy.selected_product().await?) } /// Selects the product to install pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { - let result = self.software_proxy.select_product(product_id).await?; + let result = self.product_proxy.select_product(product_id).await?; match result { (0, _) => Ok(()), diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs index b9721bbf4a..8fc081b3c6 100644 --- a/rust/agama-lib/src/software/proxies.rs +++ b/rust/agama-lib/src/software/proxies.rs @@ -39,15 +39,26 @@ trait Software1 { /// RemovePattern method fn remove_pattern(&self, id: &str) -> zbus::Result<()>; - /// SelectProduct method - fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; - /// SetUserPatterns method fn set_user_patterns(&self, ids: &[&str]) -> zbus::Result<()>; /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result; + /// SelectedPatterns property + #[dbus_proxy(property)] + fn selected_patterns(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Software1.Product", + default_service = "org.opensuse.Agama.Software1", + default_path = "/org/opensuse/Agama/Software1/Product" +)] +trait SoftwareProduct { + /// SelectProduct method + fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; + /// AvailableProducts property #[dbus_proxy(property)] fn available_products( @@ -60,10 +71,6 @@ trait Software1 { )>, >; - /// SelectedPatterns property - #[dbus_proxy(property)] - fn selected_patterns(&self) -> zbus::Result>; - /// SelectedProduct property #[dbus_proxy(property)] fn selected_product(&self) -> zbus::Result; From 847952e103ac70fb1fc4e2496e6e5af2c96f9472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 23 Oct 2023 11:32:09 +0100 Subject: [PATCH 64/97] [service] Rubocop --- service/lib/agama/software/manager.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index c0dea816fe..d536ce2bc5 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -52,7 +52,7 @@ module Software # * Manages software and product related issues. # # It shoud be splitted in separate and smaller classes. - class Manager + class Manager # rubocop:disable Metrics/ClassLength include Helpers include WithIssues include WithProgress From a78702b7af653ddcaf2a61ebe6b6993501ff08a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 23 Oct 2023 13:39:47 +0100 Subject: [PATCH 65/97] Add missing package to setup script --- setup-service.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup-service.sh b/setup-service.sh index 4760be186f..97c3dcd8af 100755 --- a/setup-service.sh +++ b/setup-service.sh @@ -39,7 +39,7 @@ test -f /etc/zypp/repos.d/d_l_python.repo || \ $SUDO zypper --non-interactive \ addrepo https://download.opensuse.org/repositories/devel:/languages:/python/openSUSE_Tumbleweed/ d_l_python $SUDO zypper --non-interactive --gpg-auto-import-keys install gcc gcc-c++ make openssl-devel ruby-devel \ - python-langtable-data git augeas-devel jemalloc-devel awk || exit 1 + python-langtable-data git augeas-devel jemalloc-devel awk suseconnect-ruby-bindings || exit 1 # only install cargo if it is not available (avoid conflicts with rustup) which cargo || $SUDO zypper --non-interactive install cargo From aa32a970e7de5cd640c1bb460f2fa70422ad32c0 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 23 Oct 2023 18:41:12 +0200 Subject: [PATCH 66/97] fix writting credentials to target system --- service/lib/agama/registration.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 56944cedbf..3466147215 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -118,8 +118,7 @@ def deregister SUSE::Connect::YaST.deactivate_system(connect_params) FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself if @credentials_file - path = File.join(SUSE::Connect::YaST::DEFAULT_CREDENTIALS_DIR, @credentials_file) - FileUtils.rm(path) + FileUtils.rm(credentials_path(@credentials_file)) @credentials_file = nil end @@ -132,7 +131,7 @@ def deregister def finish return unless reg_code - files = [@credentials_file, SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] + files = [credentials_path(@credentials_file), SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] files.each do |file| dest = File.join(Yast::Installation.destdir, file) FileUtils.cp(file, dest) @@ -189,5 +188,9 @@ def credentials_from_url(url) # if something goes wrong try to continue like if there is no credentials param nil end + + def credentials_path(file) + File.join(SUSE::Connect::YaST::DEFAULT_CREDENTIALS_DIR, file) + end end end From ba7f48295640a79c0696dbf7d17046374db0267e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 15:10:01 +0000 Subject: [PATCH 67/97] [service] Adapt to changes in master --- service/lib/agama/config.rb | 5 +- service/lib/agama/dbus/software/product.rb | 2 +- service/lib/agama/software/manager.rb | 4 +- service/lib/agama/software/product_builder.rb | 4 +- service/test/agama/config_test.rb | 45 +++++++++------ .../test/agama/dbus/software/product_test.rb | 14 ++--- service/test/agama/software/manager_test.rb | 7 +-- .../agama/software/product_builder_test.rb | 56 ++++++++++--------- 8 files changed, 76 insertions(+), 61 deletions(-) diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index c49721d63f..0e524d648c 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -142,7 +142,8 @@ def merge(config) # Elements that match the current arch. # # @example - # config.pure_data = { + # config.products #=> + # { # "ALP-Dolomite" => { # "software" => { # "installation_repositories" => [ @@ -171,7 +172,7 @@ def merge(config) # @return [Array] def arch_elements_from(*keys, property: nil) keys.map!(&:to_s) - elements = pure_data.dig(*keys) + elements = products.dig(*keys) return [] unless elements.is_a?(Array) elements.map do |element| diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 83a4c4a703..3abfd98825 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -35,7 +35,7 @@ class Product < BaseObject PATH = "/org/opensuse/Agama/Software1/Product" private_constant :PATH - # @param backend [Agama::Software] + # @param backend [Agama::Software::Manager] # @param logger [Logger] def initialize(backend, logger) super(PATH, logger: logger) diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index c0a8ac951a..cb398b00dc 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -101,8 +101,9 @@ def initialize(config, logger) # @raise {ArgumentError} If id is unknown. # # @param id [String] + # @return [Boolean] true on success. def select_product(id) - return if id == product&.id + return false if id == product&.id new_product = @products.find { |p| p.id == id } @@ -111,6 +112,7 @@ def select_product(id) @product = new_product repositories.delete_all update_issues + true end def probe diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb index 7982c37ade..0cd70a7473 100644 --- a/service/lib/agama/software/product_builder.rb +++ b/service/lib/agama/software/product_builder.rb @@ -62,8 +62,8 @@ def build # @return [Hash] def product_data_from_config(id) { - name: config.pure_data.dig(id, "software", "base_product"), - version: config.pure_data.dig(id, "software", "version"), + name: config.products.dig(id, "software", "base_product"), + version: config.products.dig(id, "software", "version"), repositories: config.arch_elements_from( id, "software", "installation_repositories", property: :url ), diff --git a/service/test/agama/config_test.rb b/service/test/agama/config_test.rb index b9dbb241d5..f6e6c4dc58 100644 --- a/service/test/agama/config_test.rb +++ b/service/test/agama/config_test.rb @@ -22,6 +22,7 @@ require_relative "../test_helper" require "yast" require "agama/config" +require "agama/product_reader" Yast.import "Arch" @@ -133,15 +134,22 @@ end describe "#arch_elements_from" do - subject { described_class.new(data) } + subject { described_class.new } + + before do + allow(Agama::ProductReader).to receive(:new).and_return(reader) + end + + let(:reader) { instance_double(Agama::ProductReader, load_products: products) } context "when the given set of keys does not match any data" do - let(:data) do - { - "Product1" => { + let(:products) do + [ + { + "id" => "Product1", "name" => "Test product 1" } - } + ] end it "returns an empty array" do @@ -150,12 +158,13 @@ end context "when the given set of keys does not contain a collection" do - let(:data) do - { - "Product1" => { + let(:products) do + [ + { + "id" => "Product1", "name" => "Test product 1" } - } + ] end it "returns an empty array" do @@ -164,9 +173,10 @@ end context "when the given set of keys contains a collection" do - let(:data) do - { - "Product1" => { + let(:products) do + [ + { + "id" => "Product1", "some" => { "collection" => [ "element1", @@ -188,7 +198,7 @@ ] } } - } + ] end before do @@ -209,9 +219,10 @@ end context "and there are no elements matching the current arch" do - let(:data) do - { - "Product1" => { + let(:products) do + [ + { + "id" => "Product1", "some" => { "collection" => [ { @@ -225,7 +236,7 @@ ] } } - } + ] end it "returns an empty list" do diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb index 57f2e442f8..e25c36e661 100644 --- a/service/test/agama/dbus/software/product_test.rb +++ b/service/test/agama/dbus/software/product_test.rb @@ -33,17 +33,17 @@ let(:backend) { Agama::Software::Manager.new(config, logger) } - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - path = File.join(FIXTURES_PATH, "root_dir/etc/agama.yaml") - YAML.safe_load(File.read(path)) - end + let(:config) { Agama::Config.new } before do + allow(config).to receive(:products).and_return(products) allow(subject).to receive(:dbus_properties_changed) end + let(:products) do + { "Tumbleweed" => {}, "ALP-Dolomite" => {} } + end + it "defines Product D-Bus interface" do expect(subject.intfs.keys).to include("org.opensuse.Agama.Software1.Product") end @@ -75,7 +75,7 @@ context "if the current product is registered" do before do - subject.select_product("Leap") + subject.select_product("Leap16") allow(backend.registration).to receive(:reg_code).and_return("123XX432") end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 1439b02b45..77bec63e2a 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -129,7 +129,6 @@ shared_examples "software issues" do |tested_method| before do - subject.select_product("Tumbleweed") allow(subject.registration).to receive(:reg_code).and_return(reg_code) end @@ -192,7 +191,7 @@ stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir) stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir) FileUtils.mkdir_p(repos_dir) - subject.select_product("Tumbleweed") + subject.select_product("ALP-Dolomite") end after do @@ -233,9 +232,9 @@ expect(products.size).to eq(3) expect(products).to all(be_a(Agama::Software::Product)) expect(products).to contain_exactly( + an_object_having_attributes(id: "ALP-Dolomite"), an_object_having_attributes(id: "Tumbleweed"), - an_object_having_attributes(id: "Leap Micro"), - an_object_having_attributes(id: "Leap") + an_object_having_attributes(id: "Leap16") ) end end diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb index e3dd6a4345..f7bf6b48c2 100644 --- a/service/test/agama/software/product_builder_test.rb +++ b/service/test/agama/software/product_builder_test.rb @@ -22,36 +22,26 @@ require_relative "../../test_helper" require "yast" require "agama/config" +require "agama/product_reader" require "agama/software/product" require "agama/software/product_builder" Yast.import "Arch" describe Agama::Software::ProductBuilder do - subject { described_class.new(config) } + before do + allow(Agama::ProductReader).to receive(:new).and_return(reader) + end - let(:config) { Agama::Config.new(data) } + let(:reader) { instance_double(Agama::ProductReader, load_products: products) } - let(:data) do - { - "products" => { - "Test1" => { - "name" => "Product Test 1", - "description" => "This is a test product named Test 1" - }, - "Test2" => { - "name" => "Product Test 2", - "description" => "This is a test product named Test 2", - "archs" => "x86_64,aarch64" - }, - "Test3" => { - "name" => "Product Test 3", - "description" => "This is a test product named Test 3", - "archs" => "ppc64,aarch64" - } - }, - "Test1" => { - "software" => { + let(:products) do + [ + { + "id" => "Test1", + "name" => "Product Test 1", + "description" => "This is a test product named Test 1", + "software" => { "installation_repositories" => [ { "url" => "https://repos/test1/x86_64/product/", @@ -92,15 +82,23 @@ "version" => "1.0" } }, - "Test2" => { - "software" => { + { + "id" => "Test2", + "name" => "Product Test 2", + "description" => "This is a test product named Test 2", + "archs" => "x86_64,aarch64", + "software" => { "mandatory_patterns" => ["pattern2-1"], "base_product" => "Test2", "version" => "2.0" } }, - "Test3" => { - "software" => { + { + "id" => "Test3", + "name" => "Product Test 3", + "description" => "This is a test product named Test 3", + "archs" => "ppc64,aarch64", + "software" => { "installation_repositories" => ["https://repos/test3/product/"], "optional_patterns" => [ { @@ -111,9 +109,13 @@ "base_product" => "Test3" } } - } + ] end + subject { described_class.new(config) } + + let(:config) { Agama::Config.new } + describe "#build" do context "for x86_64" do before do From 6709506f0bf87e5c28afbf9d25ea5ae9b0f27761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 09:48:06 +0000 Subject: [PATCH 68/97] [service] Adapt product translations to changes in registration --- service/lib/agama/dbus/software/manager.rb | 25 -------- service/lib/agama/dbus/software/product.rb | 2 +- service/lib/agama/software/product.rb | 49 ++++++++++++-- service/lib/agama/software/product_builder.rb | 1 + .../test/agama/dbus/software/manager_test.rb | 64 ------------------- .../agama/software/product_builder_test.rb | 40 ++++++++---- service/test/agama/software/product_test.rb | 64 +++++++++++++++++++ 7 files changed, 138 insertions(+), 107 deletions(-) create mode 100644 service/test/agama/software/product_test.rb diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 7217d90811..aad196e3cb 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -149,31 +149,6 @@ def register_callbacks backend.on_issues_change { issues_properties_changed } end - # find translated product description if available - # @param data [Hash] product configuration from the YAML file - # @return [String,nil] Translated product description (if available) - # or the untranslated description, nil if not found - def localized_description(data) - translations = data["translations"]&.[]("description") - lang = ENV["LANG"] || "" - - # no translations or language not set, return untranslated value - return data["description"] if !translations.is_a?(Hash) || lang.empty? - - # remove the character encoding if present - lang = lang.split(".").first - # full matching (language + country) - return translations[lang] if translations[lang] - - # remove the country part - lang = lang.split("_").first - # partial match (just the language) - return translations[lang] if translations[lang] - - # fallback to original untranslated description - data["description"] - end - USER_SELECTED_PATTERN = 0 AUTO_SELECTED_PATTERN = 1 def compute_patterns diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 3abfd98825..5085e42444 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -53,7 +53,7 @@ def issues def available_products backend.products.map do |product| - [product.id, product.display_name, { "description" => product.description }] + [product.id, product.display_name, { "description" => product.localized_description }] end end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb index 71f1ade8ac..24bb8c0b2b 100644 --- a/service/lib/agama/software/product.rb +++ b/service/lib/agama/software/product.rb @@ -30,22 +30,22 @@ class Product # Name of the product to be display. # - # @return [String] + # @return [String, nil] attr_accessor :display_name # Description of the product. # - # @return [String] + # @return [String, nil] attr_accessor :description # Internal name of the product. This is relevant for registering the product. # - # @return [String] + # @return [String, nil] attr_accessor :name # Version of the product. This is relevant for registering the product. # - # @return [String] E.g., "1.0". + # @return [String, nil] E.g., "1.0". attr_accessor :version # List of repositories. @@ -73,6 +73,19 @@ class Product # @return [Array] attr_accessor :optional_patterns + # Product translations. + # + # @example + # product.translations #=> + # { + # "description" => { + # "cs" => "Czech translation", + # "es" => "Spanish translation" + # } + # + # @return [Hash>] + attr_accessor :translations + # @param id [string] Product id. def initialize(id) @id = id @@ -81,6 +94,34 @@ def initialize(id) @optional_packages = [] @mandatory_patterns = [] @optional_patterns = [] + @translations = {} + end + + # Localized product description. + # + # If there is no translation for the current language, then the untranslated description is + # used. + # + # @return [String, nil] + def localized_description + translations = self.translations["description"] + lang = ENV["LANG"] + + # No translations or language not set, return untranslated value. + return description unless translations && lang + + # Remove the character encoding if present. + lang = lang.split(".").first + # Full matching (language + country) + return translations[lang] if translations[lang] + + # Remove the country part. + lang = lang.split("_").first + # Partial match (just the language). + return translations[lang] if translations[lang] + + # Fallback to original untranslated description. + description end end end diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb index 0cd70a7473..b4ecfeddf8 100644 --- a/service/lib/agama/software/product_builder.rb +++ b/service/lib/agama/software/product_builder.rb @@ -47,6 +47,7 @@ def build product.optional_packages = data[:optional_packages] product.mandatory_patterns = data[:mandatory_patterns] product.optional_patterns = data[:optional_patterns] + product.translations = attrs["translations"] || {} end end end diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index ea94877819..925218b9d5 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -135,68 +135,4 @@ expect(installed).to eq(true) end end - - describe "#available_base_products" do - # testing product with translations - products = { - "Tumbleweed" => { - "name" => "openSUSE Tumbleweed", - "description" => "Original description", - "translations" => { - "description" => { - "cs" => "Czech translation", - "es" => "Spanish translation" - } - } - } - } - - it "returns product ID and name" do - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[0]).to eq("Tumbleweed") - expect(product[1]).to eq("openSUSE Tumbleweed") - end - - it "returns untranslated description when the language is not set" do - allow(ENV).to receive(:[]).with("LANG").and_return(nil) - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Original description") - end - - it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Czech translation") - end - - it "returns Czech translation if locale is \"cs\"" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs") - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Czech translation") - end - - it "return untranslated description when translation is not available" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") - - # testing product without translations - untranslated = { - "Tumbleweed" => { - "name" => "openSUSE Tumbleweed", - "description" => "Original description" - } - } - expect(backend).to receive(:products).and_return(untranslated) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Original description") - end - end end diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb index f7bf6b48c2..a48bc87ceb 100644 --- a/service/test/agama/software/product_builder_test.rb +++ b/service/test/agama/software/product_builder_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2023] SUSE LLC +# Copyright (c) [2023] SUSE LLC # # All Rights Reserved. # @@ -38,10 +38,16 @@ let(:products) do [ { - "id" => "Test1", - "name" => "Product Test 1", - "description" => "This is a test product named Test 1", - "software" => { + "id" => "Test1", + "name" => "Product Test 1", + "description" => "This is a test product named Test 1", + "translations" => { + "description" => { + "cs" => "Czech", + "es" => "Spanish" + } + }, + "software" => { "installation_repositories" => [ { "url" => "https://repos/test1/x86_64/product/", @@ -141,7 +147,8 @@ mandatory_patterns: ["pattern1-1", "pattern1-2"], optional_patterns: ["pattern1-3"], mandatory_packages: ["package1-1", "package1-2", "package1-3"], - optional_packages: ["package1-5"] + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } ), an_object_having_attributes( id: "Test2", @@ -153,7 +160,8 @@ mandatory_patterns: ["pattern2-1"], optional_patterns: [], mandatory_packages: [], - optional_packages: [] + optional_packages: [], + translations: {} ) ) end @@ -183,7 +191,8 @@ mandatory_patterns: ["pattern1-1", "pattern1-2"], optional_patterns: ["pattern1-4"], mandatory_packages: ["package1-1", "package1-2", "package1-3"], - optional_packages: ["package1-5"] + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } ), an_object_having_attributes( id: "Test2", @@ -195,7 +204,8 @@ mandatory_patterns: ["pattern2-1"], optional_patterns: [], mandatory_packages: [], - optional_packages: [] + optional_packages: [], + translations: {} ), an_object_having_attributes( id: "Test3", @@ -207,7 +217,8 @@ mandatory_patterns: [], optional_patterns: ["pattern3-1"], mandatory_packages: [], - optional_packages: [] + optional_packages: [], + translations: {} ) ) end @@ -237,7 +248,8 @@ mandatory_patterns: ["pattern1-1", "pattern1-2"], optional_patterns: [], mandatory_packages: ["package1-1", "package1-2", "package1-4"], - optional_packages: ["package1-5"] + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } ), an_object_having_attributes( id: "Test3", @@ -249,7 +261,8 @@ mandatory_patterns: [], optional_patterns: [], mandatory_packages: [], - optional_packages: [] + optional_packages: [], + translations: {} ) ) end @@ -279,7 +292,8 @@ mandatory_patterns: ["pattern1-1", "pattern1-2"], optional_patterns: [], mandatory_packages: ["package1-1", "package1-2"], - optional_packages: ["package1-5"] + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } ) ) end diff --git a/service/test/agama/software/product_test.rb b/service/test/agama/software/product_test.rb new file mode 100644 index 0000000000..aab6a8397e --- /dev/null +++ b/service/test/agama/software/product_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "agama/software/product" + +describe Agama::Software::Product do + subject { described_class.new("Test") } + + describe "#localized_description" do + before do + subject.description = "Original description" + subject.translations = { + "description" => { + "cs" => "Czech translation", + "es" => "Spanish translation" + } + } + end + + it "returns untranslated description when the language is not set" do + allow(ENV).to receive(:[]).with("LANG").and_return(nil) + + expect(subject.localized_description).to eq("Original description") + end + + it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") + + expect(subject.localized_description).to eq("Czech translation") + end + + it "returns Czech translation if locale is \"cs\"" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs") + + expect(subject.localized_description).to eq("Czech translation") + end + + it "return untranslated description when translation is not available" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") + subject.translations = {} + + expect(subject.localized_description).to eq("Original description") + end + end +end From 1d27be92895129200504144f35aefdf62798a74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 24 Oct 2023 13:16:48 +0100 Subject: [PATCH 69/97] [web] Add product manager to software client --- web/src/client/software.js | 123 ++++++++++-------- web/src/client/software.test.js | 34 ++--- .../software/ProductSelectionPage.jsx | 10 +- .../software/ProductSelectionPage.test.jsx | 14 +- web/src/context/software.jsx | 7 +- 5 files changed, 106 insertions(+), 82 deletions(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index 193f90575e..9db954ff8b 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -37,6 +37,75 @@ const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; * @property {string} description - Product description */ +/** + * Product manager. + * @ignore + */ +class BaseProductManager { + /** + * @param {DBusClient} client + */ + constructor(client) { + this.client = client; + } + + /** + * Returns the list of available products. + * + * @return {Promise>} + */ + async getAll() { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.AvailableProducts.map(product => { + const [id, name, meta] = product; + return { id, name, description: meta.description?.v }; + }); + } + + /** + * Returns the selected product. + * + * @return {Promise} + */ + async getSelected() { + const products = await this.getAll(); + const proxy = await this.client.proxy(PRODUCT_IFACE); + if (proxy.SelectedProduct === "") { + return null; + } + return products.find(product => product.id === proxy.SelectedProduct); + } + + /** + * Selects a product for installation. + * + * @param {string} id - Product ID. + */ + async select(id) { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.SelectProduct(id); + } + + /** + * Registers a callback to run when properties in the Product object change. + * + * @param {(id: string) => void} handler - Callback function. + */ + onChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { + if ("SelectedProduct" in changes) { + const selected = changes.SelectedProduct.v.toString(); + handler(selected); + } + }); + } +} + +/** + * Manages product selection. + */ +class ProductManager extends WithIssues(BaseProductManager, PRODUCT_PATH) { } + /** * Software client * @@ -48,6 +117,7 @@ class SoftwareBaseClient { */ constructor(address = undefined) { this.client = new DBusClient(SOFTWARE_SERVICE, address); + this.product = new ProductManager(this.client); } /** @@ -60,19 +130,6 @@ class SoftwareBaseClient { return proxy.Probe(); } - /** - * Returns the list of available products - * - * @return {Promise>} - */ - async getProducts() { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.AvailableProducts.map(product => { - const [id, name, meta] = product; - return { id, name, description: meta.description?.v }; - }); - } - /** * Returns how much space installation takes on disk * @@ -130,48 +187,10 @@ class SoftwareBaseClient { const proxy = await this.client.proxy(SOFTWARE_IFACE); return proxy.RemovePattern(name); } - - /** - * Returns the selected product - * - * @return {Promise} - */ - async getSelectedProduct() { - const products = await this.getProducts(); - const proxy = await this.client.proxy(PRODUCT_IFACE); - if (proxy.SelectedProduct === "") { - return null; - } - return products.find(product => product.id === proxy.SelectedProduct); - } - - /** - * Selects a product for installation - * - * @param {string} id - product ID - */ - async selectProduct(id) { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.SelectProduct(id); - } - - /** - * Registers a callback to run when properties in the Software object change - * - * @param {(id: string) => void} handler - callback function - */ - onProductChange(handler) { - return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { - if ("SelectedProduct" in changes) { - const selected = changes.SelectedProduct.v.toString(); - handler(selected); - } - }); - } } /** - * Allows getting the list the available products and selecting one for installation. + * Manages software and product configuration. */ class SoftwareClient extends WithIssues( WithProgress( diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index 65fd0e2a9a..6baa381b8f 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -48,23 +48,25 @@ beforeEach(() => { }); }); -describe("#getProducts", () => { - it("returns the list of available products", async () => { - const client = new SoftwareClient(); - const availableProducts = await client.getProducts(); - expect(availableProducts).toEqual([ - { id: "MicroOS", name: "openSUSE MicroOS" }, - { id: "Tumbleweed", name: "openSUSE Tumbleweed" } - ]); +describe("#product", () => { + describe("#getAll", () => { + it("returns the list of available products", async () => { + const client = new SoftwareClient(); + const availableProducts = await client.product.getAll(); + expect(availableProducts).toEqual([ + { id: "MicroOS", name: "openSUSE MicroOS" }, + { id: "Tumbleweed", name: "openSUSE Tumbleweed" } + ]); + }); }); -}); -describe('#getSelectedProduct', () => { - it("returns the selected product", async () => { - const client = new SoftwareClient(); - const selectedProduct = await client.getSelectedProduct(); - expect(selectedProduct).toEqual( - { id: "MicroOS", name: "openSUSE MicroOS" } - ); + describe('#getSelected', () => { + it("returns the selected product", async () => { + const client = new SoftwareClient(); + const selectedProduct = await client.product.getSelected(); + expect(selectedProduct).toEqual( + { id: "MicroOS", name: "openSUSE MicroOS" } + ); + }); }); }); diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/software/ProductSelectionPage.jsx index 86b8d622ef..c4cfb3121f 100644 --- a/web/src/components/software/ProductSelectionPage.jsx +++ b/web/src/components/software/ProductSelectionPage.jsx @@ -38,7 +38,7 @@ import { Icon, Loading } from "~/components/layout"; import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; function ProductSelectionPage() { - const client = useInstallerClient(); + const { software, manager } = useInstallerClient(); const navigate = useNavigate(); const { products, selectedProduct } = useSoftware(); const previous = selectedProduct?.id; @@ -47,8 +47,8 @@ function ProductSelectionPage() { useEffect(() => { // TODO: display a notification in the UI to emphasizes that // selected product has changed - return client.software.onProductChange(() => navigate("/")); - }, [client.software, navigate]); + return software.product.onChange(() => navigate("/")); + }, [software, navigate]); const isSelected = p => p.id === selected; @@ -60,8 +60,8 @@ function ProductSelectionPage() { } // TODO: handle errors - await client.software.selectProduct(selected); - client.manager.startProbing(); + await software.product.select(selected); + manager.startProbing(); navigate("/"); }; diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/software/ProductSelectionPage.test.jsx index 197e33cb27..ecf6c981ed 100644 --- a/web/src/components/software/ProductSelectionPage.test.jsx +++ b/web/src/components/software/ProductSelectionPage.test.jsx @@ -54,10 +54,12 @@ const managerMock = { }; const softwareMock = { - getProducts: () => Promise.resolve(products), - getSelectedProduct: jest.fn(() => Promise.resolve(products[0])), - selectProduct: jest.fn().mockResolvedValue(), - onProductChange: jest.fn() + product: { + getAll: () => Promise.resolve(products), + getSelected: jest.fn(() => Promise.resolve(products[0])), + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } }; beforeEach(() => { @@ -76,7 +78,7 @@ describe("when the user chooses a product", () => { await user.click(radio); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).toHaveBeenCalledWith("MicroOS"); + expect(softwareMock.product.select).toHaveBeenCalledWith("MicroOS"); expect(managerMock.startProbing).toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); @@ -88,7 +90,7 @@ describe("when the user chooses does not change the product", () => { await screen.findByText("openSUSE Tumbleweed"); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).not.toHaveBeenCalled(); + expect(softwareMock.product.select).not.toHaveBeenCalled(); expect(managerMock.startProbing).not.toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); diff --git a/web/src/context/software.jsx b/web/src/context/software.jsx index 8e53f91405..0d018ab752 100644 --- a/web/src/context/software.jsx +++ b/web/src/context/software.jsx @@ -33,8 +33,9 @@ function SoftwareProvider({ children }) { useEffect(() => { const loadProducts = async () => { - const available = await cancellablePromise(client.software.getProducts()); - const selected = await cancellablePromise(client.software.getSelectedProduct()); + const productManager = client.software.product; + const available = await cancellablePromise(productManager.getAll()); + const selected = await cancellablePromise(productManager.getSelected()); setProducts(available); setSelectedId(selected?.id || null); }; @@ -47,7 +48,7 @@ function SoftwareProvider({ children }) { useEffect(() => { if (!client) return; - return client.software.onProductChange(setSelectedId); + return client.software.product.onChange(setSelectedId); }, [client, setSelectedId]); const value = [products, selectedId]; From 4038cbe840fdc09226ec0356eb73a53cb365d83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 24 Oct 2023 17:09:12 +0100 Subject: [PATCH 70/97] [web] Add registration to software client --- web/src/client/software.js | 97 ++++++++++++++++++++++++++++++ web/src/client/software.test.js | 102 +++++++++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index 9db954ff8b..87d2ae3850 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -29,6 +29,7 @@ const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} Product @@ -37,6 +38,20 @@ const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; * @property {string} description - Product description */ +/** + * @typedef {object} Registration + * @property {string} code - Registration code. + * @property {string} email - Registration email. + * @property {string} requirement - Registration requirement (i.e., "not-required, "optional", + * "mandatory"). + */ + +/** + * @typedef {object} ActionResult + * @property {boolean} success - Whether the action was successfuly done. + * @property {string} message - Result message. + */ + /** * Product manager. * @ignore @@ -99,6 +114,88 @@ class BaseProductManager { } }); } + + /** + * Returns the registration of the selected product. + * + * @return {Promise} + */ + async getRegistration() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const code = proxy.RegCode; + const email = proxy.Email; + const requirement = this.registrationRequirement(proxy.Requirement); + + return (code.length === 0 ? null : { code, email, requirement }); + } + + /** + * Tries to register the selected product. + * + * @param {string} code + * @param {string} [email] + * @returns {Promise} + */ + async register(code, email = "") { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Register(code, { email }); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Tries to deregister the selected product. + * + * @returns {Promise} + */ + async deregister() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Deregister(); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Registers a callback to run when the registration changes. + * + * @param {(registration: Registration) => void} handler - Callback function. + */ + onRegistrationChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, REGISTRATION_IFACE, () => { + this.getRegistration().then(handler); + }); + } + + /** + * Helper method to generate the requirement representation. + * @private + * + * @param {number} value - D-Bus registration value. + * @returns {string} + */ + registrationRequirement(value) { + let requirement; + + switch (value) { + case 0: + requirement = "not-required"; + break; + case 1: + requirement = "optional"; + break; + case 2: + requirement = "mandatory"; + break; + } + + return requirement; + } } /** diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index 6baa381b8f..b04d34206b 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -27,6 +27,7 @@ import { SoftwareClient } from "./software"; jest.mock("./dbus"); const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; const productProxy = { wait: jest.fn(), @@ -37,12 +38,15 @@ const productProxy = { SelectedProduct: "MicroOS" }; +const registrationProxy = {}; + beforeEach(() => { // @ts-ignore DBusClient.mockImplementation(() => { return { proxy: (iface) => { if (iface === PRODUCT_IFACE) return productProxy; + if (iface === REGISTRATION_IFACE) return registrationProxy; } }; }); @@ -60,7 +64,7 @@ describe("#product", () => { }); }); - describe('#getSelected', () => { + describe("#getSelected", () => { it("returns the selected product", async () => { const client = new SoftwareClient(); const selectedProduct = await client.product.getSelected(); @@ -69,4 +73,100 @@ describe("#product", () => { ); }); }); + + describe("#getRegistration", () => { + describe("if there is no registration code", () => { + beforeEach(() => { + registrationProxy.RegCode = ""; + }); + + it("returns null", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toBeNull(); + }); + }); + + describe("if there is registration code", () => { + beforeEach(() => { + registrationProxy.RegCode = "111222"; + registrationProxy.Email = "test@test.com"; + registrationProxy.Requirement = 2; + }); + + it("returns the registration", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toStrictEqual({ + code: "111222", + email: "test@test.com", + requirement: "mandatory" + }); + }); + }); + }); + + describe("#register", () => { + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); + }); + + describe("#deregister", () => { + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); + }); }); From 911bcdc438cf516beaf439edd7ddcd1f88061d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 09:27:49 +0100 Subject: [PATCH 71/97] [web] Add product provider --- web/src/components/overview/Overview.jsx | 4 +- web/src/components/overview/Overview.test.jsx | 6 +-- .../components/software/ChangeProductLink.jsx | 4 +- .../software/ChangeProductLink.test.jsx | 10 ++-- .../software/ProductSelectionPage.jsx | 4 +- .../software/ProductSelectionPage.test.jsx | 6 +-- web/src/context/agama.jsx | 6 +-- web/src/context/{software.jsx => product.jsx} | 52 ++++++++++--------- 8 files changed, 47 insertions(+), 45 deletions(-) rename web/src/context/{software.jsx => product.jsx} (53%) diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index ed13813746..8d6850dce5 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -20,7 +20,7 @@ */ import React, { useState } from "react"; -import { useSoftware } from "~/context/software"; +import { useProduct } from "~/context/product"; import { Navigate } from "react-router-dom"; import { Page, InstallButton } from "~/components/core"; @@ -33,7 +33,7 @@ import { } from "~/components/overview"; function Overview() { - const { selectedProduct } = useSoftware(); + const { selectedProduct } = useProduct(); const [showErrors, setShowErrors] = useState(false); if (selectedProduct === null) { diff --git a/web/src/components/overview/Overview.test.jsx b/web/src/components/overview/Overview.test.jsx index 05c12e0f9e..b3462eb20c 100644 --- a/web/src/components/overview/Overview.test.jsx +++ b/web/src/components/overview/Overview.test.jsx @@ -34,9 +34,9 @@ const startInstallationFn = jest.fn(); jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: mockProduct diff --git a/web/src/components/software/ChangeProductLink.jsx b/web/src/components/software/ChangeProductLink.jsx index 8dc6f07001..3eb872231a 100644 --- a/web/src/components/software/ChangeProductLink.jsx +++ b/web/src/components/software/ChangeProductLink.jsx @@ -21,12 +21,12 @@ import React from "react"; import { Link } from "react-router-dom"; -import { useSoftware } from "~/context/software"; +import { useProduct } from "~/context/product"; import { Icon } from "~/components/layout"; import { _ } from "~/i18n"; export default function ChangeProductLink() { - const { products } = useSoftware(); + const { products } = useProduct(); if (products?.length === 1) return null; diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx index c89a62222a..c5a41e3309 100644 --- a/web/src/components/software/ChangeProductLink.test.jsx +++ b/web/src/components/software/ChangeProductLink.test.jsx @@ -28,9 +28,9 @@ import { ChangeProductLink } from "~/components/software"; let mockProducts; jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, }; @@ -40,8 +40,8 @@ jest.mock("~/context/software", () => ({ beforeEach(() => { createClient.mockImplementation(() => { return { - software: { - onProductChange: jest.fn() + product: { + onChange: jest.fn() }, }; }); diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/software/ProductSelectionPage.jsx index c4cfb3121f..4b4663aaa6 100644 --- a/web/src/components/software/ProductSelectionPage.jsx +++ b/web/src/components/software/ProductSelectionPage.jsx @@ -22,7 +22,7 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useInstallerClient } from "~/context/installer"; -import { useSoftware } from "~/context/software"; +import { useProduct } from "~/context/product"; import { _ } from "~/i18n"; import { @@ -40,7 +40,7 @@ import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; function ProductSelectionPage() { const { software, manager } = useInstallerClient(); const navigate = useNavigate(); - const { products, selectedProduct } = useSoftware(); + const { products, selectedProduct } = useProduct(); const previous = selectedProduct?.id; const [selected, setSelected] = useState(selectedProduct?.id); diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/software/ProductSelectionPage.test.jsx index ecf6c981ed..b3abb5916a 100644 --- a/web/src/components/software/ProductSelectionPage.test.jsx +++ b/web/src/components/software/ProductSelectionPage.test.jsx @@ -39,9 +39,9 @@ const products = [ ]; jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products, selectedProduct: products[0] diff --git a/web/src/context/agama.jsx b/web/src/context/agama.jsx index 6306c1ba8c..0aa16e811b 100644 --- a/web/src/context/agama.jsx +++ b/web/src/context/agama.jsx @@ -24,7 +24,7 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; import { L10nProvider } from "./l10n"; -import { SoftwareProvider } from "./software"; +import { ProductProvider } from "./product"; import { NotificationProvider } from "./notification"; /** @@ -37,11 +37,11 @@ function AgamaProviders({ children }) { return ( - + {children} - + ); diff --git a/web/src/context/software.jsx b/web/src/context/product.jsx similarity index 53% rename from web/src/context/software.jsx rename to web/src/context/product.jsx index 0d018ab752..1d79959fdd 100644 --- a/web/src/context/software.jsx +++ b/web/src/context/product.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2023] SUSE LLC * * All Rights Reserved. * @@ -19,31 +19,34 @@ * find current contact information at www.suse.com. */ -import React, { useEffect } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "./installer"; -const SoftwareContext = React.createContext([]); +const ProductContext = React.createContext([]); -function SoftwareProvider({ children }) { +function ProductProvider({ children }) { const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [products, setProducts] = React.useState(undefined); - const [selectedId, setSelectedId] = React.useState(undefined); + const [products, setProducts] = useState(undefined); + const [selectedId, setSelectedId] = useState(undefined); + const [registration, setRegistration] = useState(undefined); useEffect(() => { - const loadProducts = async () => { + const load = async () => { const productManager = client.software.product; const available = await cancellablePromise(productManager.getAll()); const selected = await cancellablePromise(productManager.getSelected()); + const registration = await cancellablePromise(productManager.getRegistration()); setProducts(available); setSelectedId(selected?.id || null); + setRegistration(registration); }; if (client) { - loadProducts().catch(console.error); + load().catch(console.error); } - }, [client, setProducts, setSelectedId, cancellablePromise]); + }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]); useEffect(() => { if (!client) return; @@ -51,28 +54,27 @@ function SoftwareProvider({ children }) { return client.software.product.onChange(setSelectedId); }, [client, setSelectedId]); - const value = [products, selectedId]; - return {children}; + useEffect(() => { + if (!client) return; + + return client.software.product.onRegistrationChange(setRegistration); + }, [client, setRegistration]); + + const value = { products, selectedId, registration }; + return {children}; } -function useSoftware() { - const context = React.useContext(SoftwareContext); +function useProduct() { + const context = useContext(ProductContext); if (!context) { - throw new Error("useSoftware must be used within a SoftwareProvider"); + throw new Error("useProduct must be used within a ProductProvider"); } - const [products, selectedId] = context; - - let selectedProduct = selectedId; - if (selectedId && products) { - selectedProduct = products.find(p => p.id === selectedId) || null; - } + const { products = [], selectedId } = context; + const selectedProduct = products.find(p => p.id === selectedId); - return { - products, - selectedProduct - }; + return { ...context, selectedProduct }; } -export { SoftwareProvider, useSoftware }; +export { ProductProvider, useProduct }; From f88225d374cdc6b0ec2a10e0f91d3042ab9c53b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 17:47:01 +0100 Subject: [PATCH 72/97] [web] Allow to configure the rows of SectionSkeleton --- web/src/components/core/SectionSkeleton.jsx | 36 +++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/web/src/components/core/SectionSkeleton.jsx b/web/src/components/core/SectionSkeleton.jsx index b8e0fa8d70..bc6cf20cda 100644 --- a/web/src/components/core/SectionSkeleton.jsx +++ b/web/src/components/core/SectionSkeleton.jsx @@ -23,19 +23,27 @@ import React from "react"; import { Skeleton } from "@patternfly/react-core"; import { _ } from "~/i18n"; -const SectionSkeleton = () => ( - <> - - - -); +const SectionSkeleton = ({ numRows = 2 }) => { + const WaitingSkeleton = ({ width }) => { + return ( + + ); + }; + + return ( + <> + { + Array.from({ length: numRows }, (_, i) => { + const width = i % 2 === 0 ? "50%" : "25%"; + return ; + }) + } + + ); +}; export default SectionSkeleton; From 9ca8e76d3261761b9ec8a373f95361d2350524be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 17:48:22 +0100 Subject: [PATCH 73/97] [web] Add product section to overview --- web/src/components/overview/L10nSection.jsx | 2 +- web/src/components/overview/Overview.jsx | 2 + web/src/components/overview/Overview.test.jsx | 2 + .../components/overview/ProductSection.jsx | 72 ++++++++++++++ .../overview/ProductSection.test.jsx | 93 +++++++++++++++++++ web/src/components/overview/index.js | 3 +- .../software/ChangeProductLink.test.jsx | 12 --- 7 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 web/src/components/overview/ProductSection.jsx create mode 100644 web/src/components/overview/ProductSection.test.jsx diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index ce8c47c111..1429b7f1a8 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -61,7 +61,7 @@ export default function L10nSection({ showErrors }) { const SectionContent = () => { const { busy, languages, language } = state; - if (busy) return ; + if (busy) return ; const selected = languages.find(lang => lang.id === language); diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index 8d6850dce5..0cc9e896dc 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -27,6 +27,7 @@ import { Page, InstallButton } from "~/components/core"; import { L10nSection, NetworkSection, + ProductSection, SoftwareSection, StorageSection, UsersSection @@ -46,6 +47,7 @@ function Overview() { icon="inventory_2" action={ setShowErrors(true)} />} > + diff --git a/web/src/components/overview/Overview.test.jsx b/web/src/components/overview/Overview.test.jsx index b3462eb20c..89a0a053bd 100644 --- a/web/src/components/overview/Overview.test.jsx +++ b/web/src/components/overview/Overview.test.jsx @@ -44,6 +44,7 @@ jest.mock("~/context/product", () => ({ } })); +jest.mock("~/components/overview/ProductSection", () => () =>
Product Section
); jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); jest.mock("~/components/overview/StorageSection", () => () =>
Storage Section
); jest.mock("~/components/overview/NetworkSection", () => () =>
Network Section
); @@ -71,6 +72,7 @@ describe("when product is selected", () => { const title = screen.getByText(/openSUSE Tumbleweed/i); expect(title).toBeInTheDocument(); + await screen.findByText("Product Section"); await screen.findByText("Localization Section"); await screen.findByText("Network Section"); await screen.findByText("Storage Section"); diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx new file mode 100644 index 0000000000..8b95d792af --- /dev/null +++ b/web/src/components/overview/ProductSection.jsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { Text } from "@patternfly/react-core"; +import { useCancellablePromise } from "~/utils"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; +import { Section, SectionSkeleton } from "~/components/core"; +import { _ } from "~/i18n"; + +const errorsFrom = (issues) => { + const errors = issues.filter(i => i.severity === "error"); + return errors.map(e => ({ message: e.description })); +}; + +export default function ProductSection() { + const { software } = useInstallerClient(); + const [issues, setIssues] = useState([]); + const { selectedProduct } = useProduct(); + const { cancellablePromise } = useCancellablePromise(); + + useEffect(() => { + cancellablePromise(software.product.getIssues()).then(setIssues); + return software.product.onIssuesChange(setIssues); + }, [cancellablePromise, setIssues, software]); + + const Content = ({ isLoading = false }) => { + if (isLoading) return ; + + return ( + + {selectedProduct?.name} + + ); + }; + + const isLoading = !selectedProduct; + const errors = isLoading ? [] : errorsFrom(issues); + + return ( +
+ +
+ ); +} diff --git a/web/src/components/overview/ProductSection.test.jsx b/web/src/components/overview/ProductSection.test.jsx new file mode 100644 index 0000000000..66fa1c2f17 --- /dev/null +++ b/web/src/components/overview/ProductSection.test.jsx @@ -0,0 +1,93 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { createClient } from "~/client"; +import { ProductSection } from "~/components/overview"; + +let mockProduct; + +const mockIssue = { severity: "error", description: "Fake issue" }; + +jest.mock("~/client"); + +jest.mock("~/components/core/SectionSkeleton", () => () =>
Loading
); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ selectedProduct: mockProduct }) +})); + +beforeEach(() => { + const issues = [mockIssue]; + mockProduct = { name: "Test Product" }; + + createClient.mockImplementation(() => { + return { + software: { + product: { + getIssues: jest.fn().mockResolvedValue(issues), + onIssuesChange: jest.fn() + } + } + }; + }); +}); + +it("shows the product name", async () => { + installerRender(); + + await screen.findByText("Test Product"); +}); + +it("shows the error", async () => { + installerRender(); + + await screen.findByText("Fake issue"); +}); + +it("does not show warnings", async () => { + mockIssue.severity = "warning"; + + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); +}); + +describe("when no product is selected", () => { + beforeEach(() => { + mockProduct = undefined; + }); + + it("shows the skeleton", async () => { + installerRender(); + + await screen.findByText("Loading"); + }); + + it("does not show errors", async () => { + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); + }); +}); diff --git a/web/src/components/overview/index.js b/web/src/components/overview/index.js index bfab42832b..c46f96127a 100644 --- a/web/src/components/overview/index.js +++ b/web/src/components/overview/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -22,6 +22,7 @@ export { default as Overview } from "./Overview"; export { default as L10nSection } from "./L10nSection"; export { default as NetworkSection } from "./NetworkSection"; +export { default as ProductSection } from "./ProductSection"; export { default as SoftwareSection } from "./SoftwareSection"; export { default as StorageSection } from "./StorageSection"; export { default as UsersSection } from "./UsersSection"; diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx index c5a41e3309..55afa5e24b 100644 --- a/web/src/components/software/ChangeProductLink.test.jsx +++ b/web/src/components/software/ChangeProductLink.test.jsx @@ -22,12 +22,10 @@ import React from "react"; import { screen, waitFor } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; import { ChangeProductLink } from "~/components/software"; let mockProducts; -jest.mock("~/client"); jest.mock("~/context/product", () => ({ ...jest.requireActual("~/context/product"), useProduct: () => { @@ -37,16 +35,6 @@ jest.mock("~/context/product", () => ({ } })); -beforeEach(() => { - createClient.mockImplementation(() => { - return { - product: { - onChange: jest.fn() - }, - }; - }); -}); - describe("ChangeProductLink", () => { describe("when there is only a single product", () => { beforeEach(() => { From 4334e9a3f245b3a862a5df225d9ea578c13d9a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 25 Oct 2023 18:12:58 +0100 Subject: [PATCH 74/97] [web] Adapt title of overview page --- web/src/components/layout/Icon.jsx | 2 ++ web/src/components/overview/Overview.jsx | 7 ++++--- web/src/components/overview/Overview.test.jsx | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index f3866cccf3..57ff9259dd 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -57,6 +57,7 @@ import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import SettingsFill from "@icons/settings-fill.svg?component"; import SignalCellularAlt from "@icons/signal_cellular_alt.svg?component"; import Storage from "@icons/storage.svg?component"; +import Summarize from "@icons/summarize.svg?component"; import TaskAlt from "@icons/task_alt.svg?component"; import Terminal from "@icons/terminal.svg?component"; import Translate from "@icons/translate.svg?component"; @@ -110,6 +111,7 @@ const icons = { settings_ethernet: SettingsEthernet, signal_cellular_alt: SignalCellularAlt, storage: Storage, + summarize: Summarize, task_alt: TaskAlt, terminal: Terminal, translate: Translate, diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index 0cc9e896dc..205f82e576 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -22,7 +22,6 @@ import React, { useState } from "react"; import { useProduct } from "~/context/product"; import { Navigate } from "react-router-dom"; - import { Page, InstallButton } from "~/components/core"; import { L10nSection, @@ -32,6 +31,7 @@ import { StorageSection, UsersSection } from "~/components/overview"; +import { _ } from "~/i18n"; function Overview() { const { selectedProduct } = useProduct(); @@ -43,8 +43,9 @@ function Overview() { return ( setShowErrors(true)} />} > diff --git a/web/src/components/overview/Overview.test.jsx b/web/src/components/overview/Overview.test.jsx index 89a0a053bd..dcb0733bd5 100644 --- a/web/src/components/overview/Overview.test.jsx +++ b/web/src/components/overview/Overview.test.jsx @@ -69,7 +69,7 @@ beforeEach(() => { describe("when product is selected", () => { it("renders the Overview and the Install button", async () => { installerRender(); - const title = screen.getByText(/openSUSE Tumbleweed/i); + const title = screen.getByText(/installation summary/i); expect(title).toBeInTheDocument(); await screen.findByText("Product Section"); From b8524bd7b547faff66d2d7f54f3402f3d90b9fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 16:34:42 +0100 Subject: [PATCH 75/97] [web] Move issues management to client --- web/src/client/index.js | 50 ++++++++++++- web/src/client/issues.js | 78 -------------------- web/src/client/issues.test.js | 94 ------------------------ web/src/components/core/Sidebar.test.jsx | 20 +++-- web/src/context/notification.jsx | 5 +- 5 files changed, 63 insertions(+), 184 deletions(-) delete mode 100644 web/src/client/issues.js delete mode 100644 web/src/client/issues.test.js diff --git a/web/src/client/index.js b/web/src/client/index.js index b4d56f18e0..96079edcc3 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -30,7 +30,6 @@ import { UsersClient } from "./users"; import phase from "./phase"; import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; -import { IssuesClient } from "./issues"; import cockpit from "../lib/cockpit"; const BUS_ADDRESS_FILE = "/run/agama/bus.address"; @@ -46,13 +45,26 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; * @property {StorageClient} storage - storage client * @property {UsersClient} users - users client * @property {QuestionsClient} questions - questions client - * @property {IssuesClient} issues - issues client + * @property {() => Promise} issues - issues from all contexts + * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run + * when issues from any context change. It returns a function to deregister the handler. * @property {() => Promise} isConnected - determines whether the client is connected * @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run * when the connection is lost. It returns a function to deregister the * handler. */ +/** + * @typedef {import ("~/client/mixins").Issue} Issue + * + * @typedef {object} Issues + * @property {Issue[]} [product] - Issues from product. + * @property {Issue[]} [storage] - Issues from storage. + * @property {Issue[]} [software] - Issues from software. + * + * @typedef {(issues: Issues) => void} IssuesHandler +*/ + /** * Creates the Agama client * @@ -67,7 +79,38 @@ const createClient = (address = "unix:path=/run/agama/bus") => { const storage = new StorageClient(address); const users = new UsersClient(address); const questions = new QuestionsClient(address); - const issues = new IssuesClient({ storage }); + + /** + * Gets all issues, grouping them by context. + * + * TODO: issues are requested by several components (e.g., overview sections, notifications + * provider, issues page, storage page, etc). There should be an issues provider. + * + * @returns {Promise} + */ + const issues = async () => { + return { + product: await software.product.getIssues(), + storage: await storage.getIssues(), + software: await software.getIssues() + }; + }; + + /** + * Registers a callback to be executed when issues change. + * + * @param {IssuesHandler} handler - Callback function. + * @return {() => void} - Function to deregister the callback. + */ + const onIssuesChange = (handler) => { + const unsubscribeCallbacks = []; + + unsubscribeCallbacks.push(software.product.onIssuesChange(i => handler({ product: i }))); + unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i }))); + unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i }))); + + return () => { unsubscribeCallbacks.forEach(cb => cb()) }; + }; const isConnected = async () => { try { @@ -88,6 +131,7 @@ const createClient = (address = "unix:path=/run/agama/bus") => { users, questions, issues, + onIssuesChange, isConnected, onDisconnect: (handler) => monitor.onDisconnect(handler) }; diff --git a/web/src/client/issues.js b/web/src/client/issues.js deleted file mode 100644 index f6a38d4e26..0000000000 --- a/web/src/client/issues.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -/** - * @typedef {object} ClientsIssues - * @property {import ("~/client/mixins").Issue[]} storage - Issues from storage client - */ - -/** - * Client for managing all issues, independently on the service owning the issues - */ -class IssuesClient { - /** - * @param {object} clients - Clients managing issues - * @param {import ("~/client/storage").StorageClient} clients.storage - */ - constructor(clients) { - this.clients = clients; - } - - /** - * Get issues from all clients managing issues - * - * @returns {Promise} - */ - async getAll() { - const storage = await this.clients.storage.getIssues(); - - return { storage }; - } - - /** - * Checks whether there is some error - * - * @returns {Promise} - */ - async any() { - const clientsIssues = await this.getAll(); - const issues = Object.values(clientsIssues).flat(); - - return issues.length > 0; - } - - /** - * Registers a callback for each service to be executed when its issues change - * - * @param {import ("~/client/mixins").IssuesHandler} handler - callback function - * @return {import ("./dbus").RemoveFn} function to disable the callback - */ - onIssuesChange(handler) { - const unsubscribeCallbacks = []; - unsubscribeCallbacks.push(this.clients.storage.onIssuesChange(handler)); - - return () => { unsubscribeCallbacks.forEach(cb => cb()) }; - } -} - -export { IssuesClient }; diff --git a/web/src/client/issues.test.js b/web/src/client/issues.test.js deleted file mode 100644 index 2eec15df3f..0000000000 --- a/web/src/client/issues.test.js +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import { IssuesClient } from "./issues"; -import { StorageClient } from "./storage"; - -const storageIssues = [ - { description: "Storage issue 1", severity: "error", details: "", source: "" }, - { description: "Storage issue 2", severity: "warn", details: "", source: "" }, - { description: "Storage issue 3", severity: "error", details: "", source: "" } -]; - -const issues = { - storage: [] -}; - -jest.spyOn(StorageClient.prototype, 'getIssues').mockImplementation(async () => issues.storage); -jest.spyOn(StorageClient.prototype, 'onIssuesChange'); - -const clientsWithIssues = { - storage: new StorageClient() -}; - -describe("#getAll", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns all the storage issues", async () => { - const client = new IssuesClient(clientsWithIssues); - - const { storage } = await client.getAll(); - expect(storage).toEqual(expect.arrayContaining(storageIssues)); - }); -}); - -describe("#any", () => { - describe("if there are storage issues", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns true", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(true); - }); - }); - - describe("if there are no issues", () => { - beforeEach(() => { - issues.storage = []; - }); - - it("returns false", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(false); - }); - }); -}); - -describe("#onIssuesChange", () => { - it("subscribes to changes in storage issues", () => { - const client = new IssuesClient(clientsWithIssues); - - const handler = jest.fn(); - client.onIssuesChange(handler); - - expect(clientsWithIssues.storage.onIssuesChange).toHaveBeenCalledWith(handler); - }); -}); diff --git a/web/src/components/core/Sidebar.test.jsx b/web/src/components/core/Sidebar.test.jsx index cf20df357d..44ef3b4357 100644 --- a/web/src/components/core/Sidebar.test.jsx +++ b/web/src/components/core/Sidebar.test.jsx @@ -29,17 +29,17 @@ import { createClient } from "~/client"; jest.mock("~/components/core/LogsButton", () => () =>
LogsButton Mock
); jest.mock("~/components/software/ChangeProductLink", () => () =>
ChangeProductLink Mock
); -let hasIssues = false; +let mockIssues; jest.mock("~/client"); beforeEach(() => { + mockIssues = []; + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -137,7 +137,13 @@ describe("onClick bubbling", () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + software: [ + { + description: "software issue 1", details: "Details 1", source: "system", severity: "warn" + } + ] + }; }); it("includes a notification mark", async () => { @@ -149,7 +155,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = []; }); it("does not include a notification mark", async () => { diff --git a/web/src/context/notification.jsx b/web/src/context/notification.jsx index 38446dada1..d04df31486 100644 --- a/web/src/context/notification.jsx +++ b/web/src/context/notification.jsx @@ -37,7 +37,8 @@ function NotificationProvider({ children }) { const load = useCallback(async () => { if (!client) return; - const hasIssues = await cancellablePromise(client.issues.any()); + const issues = await cancellablePromise(client.issues()); + const hasIssues = Object.values(issues).flat().length > 0; update({ issues: hasIssues }); }, [client, cancellablePromise, update]); @@ -45,7 +46,7 @@ function NotificationProvider({ children }) { if (!client) return; load(); - return client.issues.onIssuesChange(load); + return client.onIssuesChange(load); }, [client, load]); const value = [state, update]; From bd54a8d318161f5e0372457e28e3471dd29636ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Oct 2023 17:13:54 +0100 Subject: [PATCH 76/97] [web] Show product and software issues --- web/src/client/software.js | 2 +- web/src/components/core/IssuesPage.jsx | 94 ++++++++++++--------- web/src/components/core/IssuesPage.test.jsx | 65 ++++++++++---- 3 files changed, 104 insertions(+), 57 deletions(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index 87d2ae3850..bb3157fdc6 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -48,7 +48,7 @@ const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} ActionResult - * @property {boolean} success - Whether the action was successfuly done. + * @property {boolean} success - Whether the action was successfully done. * @property {string} message - Result message. */ diff --git a/web/src/components/core/IssuesPage.jsx b/web/src/components/core/IssuesPage.jsx index 7d137c5fca..f076c6aece 100644 --- a/web/src/components/core/IssuesPage.jsx +++ b/web/src/components/core/IssuesPage.jsx @@ -21,17 +21,17 @@ import React, { useCallback, useEffect, useState } from "react"; -import { HelperText, HelperTextItem, Skeleton } from "@patternfly/react-core"; +import { HelperText, HelperTextItem } from "@patternfly/react-core"; import { partition, useCancellablePromise } from "~/utils"; -import { If, Page, Section } from "~/components/core"; +import { If, Page, Section, SectionSkeleton } from "~/components/core"; import { Icon } from "~/components/layout"; import { useInstallerClient } from "~/context/installer"; import { useNotification } from "~/context/notification"; import { _ } from "~/i18n"; /** - * Renders an issue + * Item representing an issue. * @component * * @param {object} props @@ -58,54 +58,68 @@ const IssueItem = ({ issue }) => { }; /** - * Generates a specific section with issues + * Generates issue items sorted by severity. * @component * * @param {object} props * @param {import ("~/client/mixins").Issue[]} props.issues - * @param {object} props.props */ -const IssuesSection = ({ issues, ...props }) => { - if (issues.length === 0) return null; - +const IssueItems = ({ issues = [] }) => { const sortedIssues = partition(issues, i => i.severity === "error").flat(); - const issueItems = sortedIssues.map((issue, index) => { + return sortedIssues.map((issue, index) => { return ; }); - - return ( -
- {issueItems} -
- ); }; /** - * Generates the sections with issues + * Generates the sections with issues. * @component * * @param {object} props * @param {import ("~/client/issues").ClientsIssues} props.issues */ const IssuesSections = ({ issues }) => { + const productIssues = issues.product || []; + const storageIssues = issues.storage || []; + const softwareIssues = issues.software || []; + return ( - + <> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + ); }; /** - * Generates the content for each section with issues. If there are no issues, then a success - * message is shown. + * Generates sections with issues. If there are no issues, then a success message is shown. * @component * * @param {object} props - * @param {import ("~/client/issues").ClientsIssues} props.issues + * @param {import ("~/client").Issues} props.issues */ const IssuesContent = ({ issues }) => { const NoIssues = () => { @@ -130,28 +144,32 @@ const IssuesContent = ({ issues }) => { }; /** - * Page to show all issues per section + * Page to show all issues. * @component */ export default function IssuesPage() { const [isLoading, setIsLoading] = useState(true); - const [issues, setIssues] = useState({}); - const { issues: client } = useInstallerClient(); + const [issues, setIssues] = useState(); + const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [, updateNotification] = useNotification(); + const [notification, updateNotification] = useNotification(); - const loadIssues = useCallback(async () => { + const load = useCallback(async () => { setIsLoading(true); - const allIssues = await cancellablePromise(client.getAll()); - setIssues(allIssues); + const issues = await cancellablePromise(client.issues()); setIsLoading(false); - updateNotification({ issues: false }); - }, [client, cancellablePromise, setIssues, setIsLoading, updateNotification]); + return issues; + }, [client, cancellablePromise, setIsLoading]); + + const update = useCallback((issues) => { + setIssues(current => ({ ...current, ...issues })); + if (notification.issues) updateNotification({ issues: false }); + }, [notification, setIssues, updateNotification]); useEffect(() => { - loadIssues(); - return client.onIssuesChange(loadIssues); - }, [client, loadIssues]); + load().then(update); + return client.onIssuesChange(update); + }, [client, load, update]); return ( } + then={} else={} /> diff --git a/web/src/components/core/IssuesPage.test.jsx b/web/src/components/core/IssuesPage.test.jsx index 061357e68c..97badf900d 100644 --- a/web/src/components/core/IssuesPage.test.jsx +++ b/web/src/components/core/IssuesPage.test.jsx @@ -20,37 +20,43 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender, withNotificationProvider } from "~/test-utils"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import { installerRender, createCallbackMock, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesPage } from "~/components/core"; jest.mock("~/client"); jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - return { - ...original, + ...jest.requireActual("@patternfly/react-core"), Skeleton: () =>
PFSkeleton
}; }); -let issues = { +const issues = { + product: [], storage: [ - { description: "Issue 1", details: "Details 1", source: "system", severity: "warn" }, - { description: "Issue 2", details: "Details 2", source: "config", severity: "error" } + { description: "storage issue 1", details: "Details 1", source: "system", severity: "warn" }, + { description: "storage issue 2", details: "Details 2", source: "config", severity: "error" } + ], + software: [ + { description: "software issue 1", details: "Details 1", source: "system", severity: "warn" } ] }; +let mockIssues; + +let mockOnIssuesChange; + beforeEach(() => { + mockIssues = { ...issues }; + mockOnIssuesChange = jest.fn(); + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(true), - getAll: () => Promise.resolve(issues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: mockOnIssuesChange }; }); }); @@ -59,20 +65,25 @@ it("loads the issues", async () => { installerRender(withNotificationProvider()); screen.getAllByText(/PFSkeleton/); - await screen.findByText(/Issue 1/); + await screen.findByText(/storage issue 1/); }); it("renders sections with issues", async () => { installerRender(withNotificationProvider()); - const section = await screen.findByRole("region", { name: "Storage" }); - within(section).findByText(/Issue 1/); - within(section).findByText(/Issue 2/); + await waitFor(() => expect(screen.queryByText("Product")).not.toBeInTheDocument()); + + const storageSection = await screen.findByText(/Storage/); + within(storageSection).findByText(/storage issue 1/); + within(storageSection).findByText(/storage issue 2/); + + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); }); describe("if there are not issues", () => { beforeEach(() => { - issues = { storage: [] }; + mockIssues = { product: [], storage: [], software: [] }; }); it("renders a success message", async () => { @@ -81,3 +92,21 @@ describe("if there are not issues", () => { await screen.findByText(/No issues found/); }); }); + +describe("if the issues change", () => { + it("shows the new issues", async () => { + const [mockFunction, callbacks] = createCallbackMock(); + mockOnIssuesChange = mockFunction; + + installerRender(withNotificationProvider()); + + await screen.findByText("Storage"); + + mockIssues.storage = []; + act(() => callbacks.forEach(c => c({ storage: mockIssues.storage }))); + + await waitFor(() => expect(screen.queryByText("Storage")).not.toBeInTheDocument()); + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); + }); +}); From 59ca28e3ca4fd26e41156d8dab941400a440598e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 27 Oct 2023 10:20:05 +0100 Subject: [PATCH 77/97] [web] Adapt to changes in master --- web/src/App.jsx | 4 ++-- web/src/App.test.jsx | 6 +++--- web/src/context/product.jsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index c02367ddd4..0e162ca045 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,7 +24,7 @@ import { Outlet } from "react-router-dom"; import { _ } from "~/i18n"; import { useInstallerClient, useInstallerClientStatus } from "~/context/installer"; -import { useSoftware } from "./context/software"; +import { useProduct } from "./context/product"; import { STARTUP, INSTALL } from "~/client/phase"; import { BUSY } from "~/client/status"; @@ -57,7 +57,7 @@ const ATTEMPTS = 3; function App() { const client = useInstallerClient(); const { attempt } = useInstallerClientStatus(); - const { products } = useSoftware(); + const { products } = useProduct(); const { language } = useL10n(); const [status, setStatus] = useState(undefined); const [phase, setPhase] = useState(undefined); diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 84092633b6..59b0753521 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -31,9 +31,9 @@ jest.mock("~/client"); // list of available products let mockProducts; -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: null diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx index 1d79959fdd..4f476f710a 100644 --- a/web/src/context/product.jsx +++ b/web/src/context/product.jsx @@ -72,7 +72,7 @@ function useProduct() { } const { products = [], selectedId } = context; - const selectedProduct = products.find(p => p.id === selectedId); + const selectedProduct = products.find(p => p.id === selectedId) || null; return { ...context, selectedProduct }; } From 7735acee6361189712009d1a9c929d6a3b6ffa00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 27 Oct 2023 14:25:59 +0100 Subject: [PATCH 78/97] [web] Extract product selection form to separate component --- .../product/ProductSelectionForm.jsx | 73 +++++++++++++++ .../product/ProductSelectionForm.test.jsx | 91 +++++++++++++++++++ web/src/components/product/index.js | 22 +++++ .../software/ProductSelectionPage.jsx | 66 +++----------- 4 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 web/src/components/product/ProductSelectionForm.jsx create mode 100644 web/src/components/product/ProductSelectionForm.test.jsx create mode 100644 web/src/components/product/index.js diff --git a/web/src/components/product/ProductSelectionForm.jsx b/web/src/components/product/ProductSelectionForm.jsx new file mode 100644 index 0000000000..7e7da0b557 --- /dev/null +++ b/web/src/components/product/ProductSelectionForm.jsx @@ -0,0 +1,73 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { Card, CardBody, Form, FormGroup, Radio } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { If } from "~/components/core"; +import { noop } from "~/utils"; +import { useProduct } from "~/context/product"; + +const ProductOptions = ({ value, onOptionClick = noop }) => { + const { products } = useProduct(); + + const isSelected = (product) => product.id === value; + + const options = products.map((p) => ( + + + onOptionClick(p.id)} + /> + + + )); + + return options; +}; + +export default function ProductSelectionForm({ id, onSubmit: onSubmitProp = noop }) { + const { products, selectedProduct } = useProduct(); + const [selected, setSelected] = useState(selectedProduct?.id); + + const onSubmit = async (e) => { + e.preventDefault(); + onSubmitProp(selected); + }; + + return ( +
+ + {_("No products found")}

} + else={} + /> +
+
+ ); +} diff --git a/web/src/components/product/ProductSelectionForm.test.jsx b/web/src/components/product/ProductSelectionForm.test.jsx new file mode 100644 index 0000000000..8f10946ca4 --- /dev/null +++ b/web/src/components/product/ProductSelectionForm.test.jsx @@ -0,0 +1,91 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { ProductSelectionForm } from "~/components/product"; +import { createClient } from "~/client"; + +let mockProducts; +let mockSelectedProduct; + +jest.mock("~/client"); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { + return { + products: mockProducts, + selectedProduct: mockSelectedProduct + }; + } +})); + +const products = [ + { + id: "ALP-Dolomite", + name: "ALP Dolomite", + description: "ALP Dolomite description" + }, + { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + description: "Tumbleweed description..." + }, + { + id: "MicroOS", + name: "openSUSE MicroOS", + description: "MicroOS description" + } +]; + +beforeEach(() => { + mockProducts = products; + mockSelectedProduct = products[0]; + + createClient.mockImplementation(() => ({})); +}); + +it("shows an option for each product", async () => { + installerRender(); + await screen.findByRole("radio", { name: "ALP Dolomite" }); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await screen.findByRole("radio", { name: "openSUSE MicroOS" }); +}); + +it("selects the current product by default", async () => { + installerRender(); + await screen.findByRole("radio", { name: "ALP Dolomite", checked: true }); +}); + +it("selects the clicked product", async () => { + const { user } = installerRender(); + const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await user.click(radio); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); +}); + +it("shows a message if there is no product for selection", async () => { + mockProducts = []; + installerRender(); + await screen.findByText(/no products found/i); +}); diff --git a/web/src/components/product/index.js b/web/src/components/product/index.js new file mode 100644 index 0000000000..fe40d93538 --- /dev/null +++ b/web/src/components/product/index.js @@ -0,0 +1,22 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +export { default as ProductSelectionForm } from "./ProductSelectionForm"; diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/software/ProductSelectionPage.jsx index 4b4663aaa6..2d9c3fc724 100644 --- a/web/src/components/software/ProductSelectionPage.jsx +++ b/web/src/components/software/ProductSelectionPage.jsx @@ -19,30 +19,21 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { useInstallerClient } from "~/context/installer"; -import { useProduct } from "~/context/product"; -import { _ } from "~/i18n"; - -import { - Button, - Card, - CardBody, - Form, - FormGroup, - Radio -} from "@patternfly/react-core"; +import { Button } from "@patternfly/react-core"; +import { _ } from "~/i18n"; import { Icon, Loading } from "~/components/layout"; +import { ProductSelectionForm } from "~/components/product"; import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; function ProductSelectionPage() { - const { software, manager } = useInstallerClient(); + const { manager, software } = useInstallerClient(); const navigate = useNavigate(); - const { products, selectedProduct } = useProduct(); - const previous = selectedProduct?.id; - const [selected, setSelected] = useState(selectedProduct?.id); + const { selectedProduct, products } = useProduct(); useEffect(() => { // TODO: display a notification in the UI to emphasizes that @@ -50,18 +41,13 @@ function ProductSelectionPage() { return software.product.onChange(() => navigate("/")); }, [software, navigate]); - const isSelected = p => p.id === selected; - - const accept = async (e) => { - e.preventDefault(); - if (selected === previous) { - navigate("/"); - return; + const onSubmit = async (id) => { + if (id !== selectedProduct?.id) { + // TODO: handle errors + await software.product.select(id); + manager.startProbing(); } - // TODO: handle errors - await software.product.select(selected); - manager.startProbing(); navigate("/"); }; @@ -69,25 +55,6 @@ function ProductSelectionPage() { ); - const buildOptions = () => { - const options = products.map((p) => ( - - - setSelected(p.id)} - /> - - - )); - - return options; - }; - return ( <> {/* TRANSLATORS: page header */} @@ -99,12 +66,7 @@ function ProductSelectionPage() { {_("Select")} - -
- - {buildOptions()} - -
+ ); } From 825b5e3ee65948162ce16bd5cb00bb69182c2e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 27 Oct 2023 15:17:09 +0100 Subject: [PATCH 79/97] [web] Move product selection to products components --- .../components/{software => product}/ProductSelectionPage.jsx | 0 .../{software => product}/ProductSelectionPage.test.jsx | 2 +- web/src/components/product/index.js | 1 + web/src/components/software/index.js | 1 - web/src/index.js | 3 ++- 5 files changed, 4 insertions(+), 3 deletions(-) rename web/src/components/{software => product}/ProductSelectionPage.jsx (100%) rename web/src/components/{software => product}/ProductSelectionPage.test.jsx (97%) diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx similarity index 100% rename from web/src/components/software/ProductSelectionPage.jsx rename to web/src/components/product/ProductSelectionPage.jsx diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx similarity index 97% rename from web/src/components/software/ProductSelectionPage.test.jsx rename to web/src/components/product/ProductSelectionPage.test.jsx index b3abb5916a..89a943a16e 100644 --- a/web/src/components/software/ProductSelectionPage.test.jsx +++ b/web/src/components/product/ProductSelectionPage.test.jsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; -import { ProductSelectionPage } from "~/components/software"; +import { ProductSelectionPage } from "~/components/product"; import { createClient } from "~/client"; const products = [ diff --git a/web/src/components/product/index.js b/web/src/components/product/index.js index fe40d93538..e3bd2cbde7 100644 --- a/web/src/components/product/index.js +++ b/web/src/components/product/index.js @@ -20,3 +20,4 @@ */ export { default as ProductSelectionForm } from "./ProductSelectionForm"; +export { default as ProductSelectionPage } from "./ProductSelectionPage"; diff --git a/web/src/components/software/index.js b/web/src/components/software/index.js index b2c6fef467..84f7a58f05 100644 --- a/web/src/components/software/index.js +++ b/web/src/components/software/index.js @@ -19,7 +19,6 @@ * find current contact information at www.suse.com. */ -export { default as ProductSelectionPage } from "./ProductSelectionPage"; export { default as ChangeProductLink } from "./ChangeProductLink"; export { default as PatternSelector } from "./PatternSelector"; export { default as UsedSize } from "./UsedSize"; diff --git a/web/src/index.js b/web/src/index.js index 3b7b6815f3..f47f5a76b5 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -37,7 +37,8 @@ import App from "~/App"; import Main from "~/Main"; import DevServerWrapper from "~/DevServerWrapper"; import { Overview } from "~/components/overview"; -import { ProductSelectionPage, SoftwarePage } from "~/components/software"; +import { ProductSelectionPage } from "~/components/product"; +import { SoftwarePage } from "~/components/software"; import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage"; import { UsersPage } from "~/components/users"; import { L10nPage } from "~/components/l10n"; From e3122a4c9ddcfac73a5acde9440aa705e65f2211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 30 Oct 2023 12:50:37 +0000 Subject: [PATCH 80/97] [web] Create ProductSelector instead of form --- .../product/ProductSelectionForm.jsx | 73 ------------------- .../product/ProductSelectionPage.jsx | 25 ++++--- .../components/product/ProductSelector.jsx | 54 ++++++++++++++ ...Form.test.jsx => ProductSelector.test.jsx} | 38 +++------- web/src/components/product/index.js | 2 +- 5 files changed, 82 insertions(+), 110 deletions(-) delete mode 100644 web/src/components/product/ProductSelectionForm.jsx create mode 100644 web/src/components/product/ProductSelector.jsx rename web/src/components/product/{ProductSelectionForm.test.jsx => ProductSelector.test.jsx} (69%) diff --git a/web/src/components/product/ProductSelectionForm.jsx b/web/src/components/product/ProductSelectionForm.jsx deleted file mode 100644 index 7e7da0b557..0000000000 --- a/web/src/components/product/ProductSelectionForm.jsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useState } from "react"; -import { Card, CardBody, Form, FormGroup, Radio } from "@patternfly/react-core"; - -import { _ } from "~/i18n"; -import { If } from "~/components/core"; -import { noop } from "~/utils"; -import { useProduct } from "~/context/product"; - -const ProductOptions = ({ value, onOptionClick = noop }) => { - const { products } = useProduct(); - - const isSelected = (product) => product.id === value; - - const options = products.map((p) => ( - - - onOptionClick(p.id)} - /> - - - )); - - return options; -}; - -export default function ProductSelectionForm({ id, onSubmit: onSubmitProp = noop }) { - const { products, selectedProduct } = useProduct(); - const [selected, setSelected] = useState(selectedProduct?.id); - - const onSubmit = async (e) => { - e.preventDefault(); - onSubmitProp(selected); - }; - - return ( -
- - {_("No products found")}

} - else={} - /> -
-
- ); -} diff --git a/web/src/components/product/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx index 2d9c3fc724..585747a9db 100644 --- a/web/src/components/product/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -19,13 +19,13 @@ * find current contact information at www.suse.com. */ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button } from "@patternfly/react-core"; +import { Button, Form, FormGroup } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Icon, Loading } from "~/components/layout"; -import { ProductSelectionForm } from "~/components/product"; +import { ProductSelector } from "~/components/product"; import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; import { useInstallerClient } from "~/context/installer"; import { useProduct } from "~/context/product"; @@ -33,7 +33,8 @@ import { useProduct } from "~/context/product"; function ProductSelectionPage() { const { manager, software } = useInstallerClient(); const navigate = useNavigate(); - const { selectedProduct, products } = useProduct(); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); useEffect(() => { // TODO: display a notification in the UI to emphasizes that @@ -41,10 +42,12 @@ function ProductSelectionPage() { return software.product.onChange(() => navigate("/")); }, [software, navigate]); - const onSubmit = async (id) => { - if (id !== selectedProduct?.id) { + const onSubmit = async (e) => { + e.preventDefault(); + + if (newProductId !== selectedProduct?.id) { // TODO: handle errors - await software.product.select(id); + await software.product.select(newProductId); manager.startProbing(); } @@ -61,12 +64,16 @@ function ProductSelectionPage() { {_("Product selection")} - - +
+ + + +
); } diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx new file mode 100644 index 0000000000..05b2842bf6 --- /dev/null +++ b/web/src/components/product/ProductSelector.jsx @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Card, CardBody, Radio } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { If } from "~/components/core"; +import { noop } from "~/utils"; + +export default function ProductSelector({ value, products = [], onChange = noop }) { + const isSelected = (product) => product.id === value; + + return ( + {_("No products available for selection")}

} + else={ + products.map((p) => ( + + + onChange(p.id)} + /> + + + )) + } + /> + ); +} diff --git a/web/src/components/product/ProductSelectionForm.test.jsx b/web/src/components/product/ProductSelector.test.jsx similarity index 69% rename from web/src/components/product/ProductSelectionForm.test.jsx rename to web/src/components/product/ProductSelector.test.jsx index 8f10946ca4..b40af415a0 100644 --- a/web/src/components/product/ProductSelectionForm.test.jsx +++ b/web/src/components/product/ProductSelector.test.jsx @@ -22,24 +22,11 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { ProductSelectionForm } from "~/components/product"; +import { ProductSelector } from "~/components/product"; import { createClient } from "~/client"; -let mockProducts; -let mockSelectedProduct; - jest.mock("~/client"); -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), - useProduct: () => { - return { - products: mockProducts, - selectedProduct: mockSelectedProduct - }; - } -})); - const products = [ { id: "ALP-Dolomite", @@ -59,33 +46,30 @@ const products = [ ]; beforeEach(() => { - mockProducts = products; - mockSelectedProduct = products[0]; - createClient.mockImplementation(() => ({})); }); it("shows an option for each product", async () => { - installerRender(); + installerRender(); await screen.findByRole("radio", { name: "ALP Dolomite" }); await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); await screen.findByRole("radio", { name: "openSUSE MicroOS" }); }); -it("selects the current product by default", async () => { - installerRender(); - await screen.findByRole("radio", { name: "ALP Dolomite", checked: true }); +it("selects the given value", async () => { + installerRender(); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); }); -it("selects the clicked product", async () => { - const { user } = installerRender(); +it("calls onChange if a new option is clicked", async () => { + const onChangeFn = jest.fn(); + const { user } = installerRender(); const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); await user.click(radio); - await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); + expect(onChangeFn).toHaveBeenCalledWith("Tumbleweed"); }); it("shows a message if there is no product for selection", async () => { - mockProducts = []; - installerRender(); - await screen.findByText(/no products found/i); + installerRender(); + await screen.findByText(/no products available/i); }); diff --git a/web/src/components/product/index.js b/web/src/components/product/index.js index e3bd2cbde7..12e0a814b0 100644 --- a/web/src/components/product/index.js +++ b/web/src/components/product/index.js @@ -19,5 +19,5 @@ * find current contact information at www.suse.com. */ -export { default as ProductSelectionForm } from "./ProductSelectionForm"; export { default as ProductSelectionPage } from "./ProductSelectionPage"; +export { default as ProductSelector } from "./ProductSelector"; From a483046cf72acaa0449f6d7422c0578cf5dd9f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 30 Oct 2023 12:52:07 +0000 Subject: [PATCH 81/97] [web] Change icon for summary --- web/src/components/layout/Icon.jsx | 4 ++-- web/src/components/overview/Overview.jsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index 57ff9259dd..42c32748af 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -44,6 +44,7 @@ import HomeStorage from "@icons/home_storage.svg?component"; import Info from "@icons/info.svg?component"; import Inventory from "@icons/inventory_2.svg?component"; import Lan from "@icons/lan.svg?component"; +import ListAlt from "@icons/list_alt.svg?component"; import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; import Menu from "@icons/menu.svg?component"; @@ -57,7 +58,6 @@ import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import SettingsFill from "@icons/settings-fill.svg?component"; import SignalCellularAlt from "@icons/signal_cellular_alt.svg?component"; import Storage from "@icons/storage.svg?component"; -import Summarize from "@icons/summarize.svg?component"; import TaskAlt from "@icons/task_alt.svg?component"; import Terminal from "@icons/terminal.svg?component"; import Translate from "@icons/translate.svg?component"; @@ -98,6 +98,7 @@ const icons = { inventory_2: Inventory, lan: Lan, loading: Loading, + list_alt: ListAlt, lock: Lock, manage_accounts: ManageAccounts, menu: Menu, @@ -111,7 +112,6 @@ const icons = { settings_ethernet: SettingsEthernet, signal_cellular_alt: SignalCellularAlt, storage: Storage, - summarize: Summarize, task_alt: TaskAlt, terminal: Terminal, translate: Translate, diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index 205f82e576..eebce05b48 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -45,7 +45,7 @@ function Overview() { setShowErrors(true)} />} > From 3bf7a232e5633c5bf74ed69e53615996ebe330a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 2 Nov 2023 10:30:21 +0000 Subject: [PATCH 82/97] [web] Fix method for getting the registration --- web/src/client/software.js | 16 ++++++++++------ web/src/client/software.test.js | 29 ++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/web/src/client/software.js b/web/src/client/software.js index bb3157fdc6..2bb678446c 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -40,10 +40,10 @@ const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} Registration - * @property {string} code - Registration code. - * @property {string} email - Registration email. * @property {string} requirement - Registration requirement (i.e., "not-required, "optional", * "mandatory"). + * @property {string|null} code - Registration code, if any. + * @property {string|null} email - Registration email, if any. */ /** @@ -118,15 +118,19 @@ class BaseProductManager { /** * Returns the registration of the selected product. * - * @return {Promise} + * @return {Promise} */ async getRegistration() { const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const requirement = this.registrationRequirement(proxy.Requirement); const code = proxy.RegCode; const email = proxy.Email; - const requirement = this.registrationRequirement(proxy.Requirement); - return (code.length === 0 ? null : { code, email, requirement }); + const registration = { requirement, code, email }; + if (code.length === 0) registration.code = null; + if (email.length === 0) registration.email = null; + + return registration; } /** @@ -138,7 +142,7 @@ class BaseProductManager { */ async register(code, email = "") { const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); - const result = await proxy.Register(code, { email }); + const result = await proxy.Register(code, { Email: { t: "s", v: email } }); return { success: result[0] === 0, diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index b04d34206b..6c68a69b4e 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -75,26 +75,32 @@ describe("#product", () => { }); describe("#getRegistration", () => { - describe("if there is no registration code", () => { + describe("if there the product is not registered yet", () => { beforeEach(() => { registrationProxy.RegCode = ""; + registrationProxy.Email = ""; + registrationProxy.Requirement = 1; }); - it("returns null", async () => { + it("returns the expected registration", async () => { const client = new SoftwareClient(); const registration = await client.product.getRegistration(); - expect(registration).toBeNull(); + expect(registration).toStrictEqual({ + code: null, + email: null, + requirement: "optional" + }); }); }); - describe("if there is registration code", () => { + describe("if the product is registered", () => { beforeEach(() => { registrationProxy.RegCode = "111222"; registrationProxy.Email = "test@test.com"; registrationProxy.Requirement = 2; }); - it("returns the registration", async () => { + it("returns the expected registration", async () => { const client = new SoftwareClient(); const registration = await client.product.getRegistration(); expect(registration).toStrictEqual({ @@ -107,6 +113,19 @@ describe("#product", () => { }); describe("#register", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("performs the expected D-Bus call", async () => { + const client = new SoftwareClient(); + await client.product.register("111222", "test@test.com"); + expect(registrationProxy.Register).toHaveBeenCalledWith( + "111222", + { Email: { t: "s", v: "test@test.com" } } + ); + }); + describe("when the action is correctly done", () => { beforeEach(() => { registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); From 5738769f9694150262aaae11082924d0625e1abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 2 Nov 2023 13:38:50 +0000 Subject: [PATCH 83/97] [web] Add EmailInput component --- web/src/components/core/EmailInput.jsx | 86 +++++++++++++++++++ web/src/components/core/EmailInput.test.jsx | 92 +++++++++++++++++++++ web/src/components/core/index.js | 1 + 3 files changed, 179 insertions(+) create mode 100644 web/src/components/core/EmailInput.jsx create mode 100644 web/src/components/core/EmailInput.test.jsx diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.jsx new file mode 100644 index 0000000000..dc84711157 --- /dev/null +++ b/web/src/components/core/EmailInput.jsx @@ -0,0 +1,86 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { InputGroup, TextInput } from "@patternfly/react-core"; +import { noop } from "~/utils"; + +/** + * Email validation. + * + * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js + * + * @param {string} email + * @returns {boolean} + */ +const validateEmail = (email) => { + const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + + const validateFormat = (email) => { + const parts = email.split('@'); + + return parts.length === 2 && regexp.test(email); + }; + + const validateSizes = (email) => { + const [account, address] = email.split('@'); + + if (account.length > 64) return false; + if (address.length > 255) return false; + + const domainParts = address.split('.'); + + if (domainParts.find(p => p.length > 63)) return false; + + return true; + }; + + return validateFormat(email) && validateSizes(email); +}; + +/** + * Renders an email input field which validates its value. + * @component + * + * @param {(boolean) => void} onValidate - Callback to be called every time the input value is + * validated. + * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * except `type` and `validated` which are managed by the component. + */ +export default function EmailInput({ onValidate = noop, ...props }) { + const [isValid, setIsValid] = useState(true); + + useEffect(() => { + const isValid = props.value.length === 0 || validateEmail(props.value); + setIsValid(isValid); + onValidate(isValid); + }, [onValidate, props.value, setIsValid]); + + return ( + + + + ); +} diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.jsx new file mode 100644 index 0000000000..5834ca8a21 --- /dev/null +++ b/web/src/components/core/EmailInput.test.jsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { screen } from "@testing-library/react"; + +import EmailInput from "./EmailInput"; +import { plainRender } from "~/test-utils"; + +describe("EmailInput component", () => { + it("renders an email input", () => { + plainRender( + + ); + + const inputField = screen.getByLabelText("User email"); + expect(inputField).toHaveAttribute("type", "email"); + }); + + // Using a controlled component for testing the rendered result instead of testing if + // the given onChange callback is called. The former is more aligned with the + // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ + const EmailInputTest = (props) => { + const [email, setEmail] = useState(""); + const [isValid, setIsValid] = useState(true); + + return ( + <> + setEmail(v)} + onValidate={setIsValid} + /> + {email &&

Email value updated!

} + {isValid === false &&

Email is not valid!

} + + ); + }; + + it("triggers onChange callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByLabelText("Test email"); + + expect(screen.queryByText("Email value updated!")).toBeNull(); + + await user.type(emailInput, "test@test.com"); + screen.getByText("Email value updated!"); + }); + + it("triggers onValidate callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByLabelText("Test email"); + + expect(screen.queryByText("Email is not valid!")).toBeNull(); + + await user.type(emailInput, "foo"); + await screen.findByText("Email is not valid!"); + }); + + it("marks the input as invalid if the value is not a valid email", async () => { + const { user } = plainRender(); + const emailInput = screen.getByLabelText("Test email"); + + await user.type(emailInput, "foo"); + + expect(emailInput).toHaveAttribute("aria-invalid"); + }); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index d457f49889..73593f96ed 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -30,6 +30,7 @@ export { default as FormLabel } from "./FormLabel"; export { default as FormValidationError } from "./FormValidationError"; export { default as Fieldset } from "./Fieldset"; export { default as Em } from "./Em"; +export { default as EmailInput } from "./EmailInput"; export { default as If } from "./If"; export { default as Installation } from "./Installation"; export { default as InstallationFinished } from "./InstallationFinished"; From 85caaefd51b8d3b45c308b3f62c479681dc51440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 09:46:03 +0000 Subject: [PATCH 84/97] [web] Some code improvements --- .../components/overview/ProductSection.jsx | 20 +++++++++++-------- .../components/overview/SoftwareSection.jsx | 14 +++++-------- .../components/software/PatternSelector.jsx | 3 ++- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx index 8b95d792af..1c4ebd89b5 100644 --- a/web/src/components/overview/ProductSection.jsx +++ b/web/src/components/overview/ProductSection.jsx @@ -21,15 +21,15 @@ import React, { useEffect, useState } from "react"; import { Text } from "@patternfly/react-core"; -import { useCancellablePromise } from "~/utils"; +import { toValidationError, useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; import { useProduct } from "~/context/product"; -import { Section, SectionSkeleton } from "~/components/core"; +import { If, Section, SectionSkeleton } from "~/components/core"; import { _ } from "~/i18n"; const errorsFrom = (issues) => { const errors = issues.filter(i => i.severity === "error"); - return errors.map(e => ({ message: e.description })); + return errors.map(toValidationError); }; export default function ProductSection() { @@ -44,12 +44,16 @@ export default function ProductSection() { }, [cancellablePromise, setIssues, software]); const Content = ({ isLoading = false }) => { - if (isLoading) return ; - return ( - - {selectedProduct?.name} - + } + else={ + + {selectedProduct?.name} + + } + /> ); }; diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx index fd9867e5e1..fbcd81cee8 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -20,13 +20,13 @@ */ import React, { useReducer, useEffect } from "react"; +import { BUSY } from "~/client/status"; import { Button } from "@patternfly/react-core"; -import { ProgressText, Section } from "~/components/core"; import { Icon } from "~/components/layout"; +import { ProgressText, Section } from "~/components/core"; +import { toValidationError, useCancellablePromise } from "~/utils"; import { UsedSize } from "~/components/software"; -import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; -import { BUSY } from "~/client/status"; import { _ } from "~/i18n"; const initialState = { @@ -78,10 +78,6 @@ export default function SoftwareSection({ showErrors }) { return client.onStatusChange(updateStatus); }, [client, cancellablePromise]); - useEffect(() => { - cancellablePromise(client.getStatus()).then(updateStatus); - }, [client, cancellablePromise]); - useEffect(() => { const updateProposal = async () => { const errors = await cancellablePromise(client.getIssues()); @@ -145,7 +141,7 @@ export default function SoftwareSection({ showErrors }) { title={_("Software")} icon="apps" loading={state.busy} - errors={errors.map(e => ({ message: e.description }))} + errors={errors.map(toValidationError)} path="/software" > diff --git a/web/src/components/software/PatternSelector.jsx b/web/src/components/software/PatternSelector.jsx index 4a64cb551d..30b550a947 100644 --- a/web/src/components/software/PatternSelector.jsx +++ b/web/src/components/software/PatternSelector.jsx @@ -27,6 +27,7 @@ import { useInstallerClient } from "~/context/installer"; import { Section, ValidationErrors } from "~/components/core"; import PatternGroup from "./PatternGroup"; import PatternItem from "./PatternItem"; +import { toValidationError } from "~/utils"; import UsedSize from "./UsedSize"; import { _ } from "~/i18n"; @@ -207,7 +208,7 @@ function PatternSelector() { // FIXME: ValidationErrors should be replaced by an equivalent component to show issues. // Note that only the Users client uses the old Validation D-Bus interface. - const validationErrors = errors.map(e => ({ message: e.description })); + const validationErrors = errors.map(toValidationError); return ( <> From 403cdde155f7d46bbb071218e9c5426d0c1ce06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 09:46:50 +0000 Subject: [PATCH 85/97] [web] Add product page --- .../components/overview/ProductSection.jsx | 2 +- web/src/components/product/ProductPage.jsx | 435 ++++++++++++++++++ .../components/product/ProductPage.test.jsx | 382 +++++++++++++++ .../product/ProductRegistrationForm.jsx | 76 +++ .../product/ProductRegistrationForm.test.jsx | 93 ++++ web/src/components/product/index.js | 2 + web/src/index.js | 3 +- 7 files changed, 991 insertions(+), 2 deletions(-) create mode 100644 web/src/components/product/ProductPage.jsx create mode 100644 web/src/components/product/ProductPage.test.jsx create mode 100644 web/src/components/product/ProductRegistrationForm.jsx create mode 100644 web/src/components/product/ProductRegistrationForm.test.jsx diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx index 1c4ebd89b5..935d20ef16 100644 --- a/web/src/components/overview/ProductSection.jsx +++ b/web/src/components/overview/ProductSection.jsx @@ -68,7 +68,7 @@ export default function ProductSection() { icon="inventory_2" errors={errors} loading={isLoading} - path="/products" + path="/product" > diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx new file mode 100644 index 0000000000..4814f33231 --- /dev/null +++ b/web/src/components/product/ProductPage.jsx @@ -0,0 +1,435 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// cspell:ignore Deregistration + +import React, { useEffect, useState } from "react"; +import { Alert, Button, Form } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { _ } from "~/i18n"; +import { BUSY } from "~/client/status"; +import { If, Page, Popup, Section } from "~/components/core"; +import { noop, useCancellablePromise } from "~/utils"; +import { ProductRegistrationForm, ProductSelector } from "~/components/product"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; + +/** + * Popup for selecting a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly selected. + * @param {function} props.onCancel - Callback to be called when the product selection is canceled. + */ +const ProductSelectionPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => { + const { manager, software } = useInstallerClient(); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (newProductId !== selectedProduct?.id) { + await software.product.select(newProductId); + manager.startProbing(); + } + + onFinish(); + }; + + return ( + +
+ + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * registered. + * @param {function} props.onCancel - Callback to be called when the product registration is + * canceled. + */ +const ProductRegistrationPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [isFormValid, setIsFormValid] = useState(true); + const [error, setError] = useState(); + + const onSubmit = async ({ code, email }) => { + setIsLoading(true); + const result = await software.product.register(code, email); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + const isDisabled = isLoading || !isFormValid; + + return ( + + +

{error}

+ + } + /> + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to deregister a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * deregistered. + * @param {function} props.onCancel - Callback to be called when the product de-registration is + * canceled. + */ +const ProductDeregistrationPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + + const onAccept = async () => { + setIsLoading(true); + const result = await software.product.deregister(); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + return ( + + +

{error}

+ + } + /> +

+ {sprintf(_("Do you want to deregister %s?"), selectedProduct.name)} +

+ + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to show a warning when there is a registered product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onAccept - Callback to be called when the warning is accepted. + */ +const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => { + const { selectedProduct } = useProduct(); + + return ( + +

+ { + sprintf( + _("The product %s must be deregistered before selecting a new product."), + selectedProduct.name + ) + } +

+ + + {_("Accept")} + + +
+ ); +}; + +/** + * Buttons for a product that does not require registration. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const WithoutRegistrationButtons = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + return ( + <> + + setIsPopupOpen(false)} + onCancel={() => setIsPopupOpen(false)} + /> + + ); +}; + +/** + * Buttons for a product that is not registered yet. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const DeregisteredButtons = ({ isDisabled = false }) => { + const [isRegistrationPopupOpen, setIsRegistrationPopupOpen] = useState(false); + const [isSelectionPopupOpen, setIsSelectionPopupOpen] = useState(false); + + return ( + <> + + + setIsRegistrationPopupOpen(false)} + onCancel={() => setIsRegistrationPopupOpen(false)} + /> + setIsSelectionPopupOpen(false)} + onCancel={() => setIsSelectionPopupOpen(false)} + /> + + ); +}; + +/** + * Buttons for a product that is already registered. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const RegisteredButtons = ({ isDisabled = false }) => { + const [isDeregistrationPopupOpen, setIsDeregistrationPopupOpen] = useState(false); + const [isWarningPopupOpen, setIsWarningPopupOpen] = useState(false); + + return ( + <> + + + setIsDeregistrationPopupOpen(false)} + onCancel={() => setIsDeregistrationPopupOpen(false)} + /> + setIsWarningPopupOpen(false)} + /> + + ); +}; + +/** + * Renders the actions for the current product. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const ProductActions = ({ isDisabled = false }) => { + const { registration } = useProduct(); + + const withRegistration = registration.requirement !== "not-required"; + const registered = registration.code !== null; + + return ( + <> +
+ } + else={} + /> + } + else={} + /> +
+ {_("Configuring product. Actions are disabled until the product is configured.")}

+ } + /> + + ); +}; + +/** + * Page for configuring a product. + * @component + */ +export default function ProductPage() { + const [managerStatus, setManagerStatus] = useState(); + const [softwareStatus, setSoftwareStatus] = useState(); + const { cancellablePromise } = useCancellablePromise(); + const { manager, software } = useInstallerClient(); + const { selectedProduct, registration } = useProduct(); + + useEffect(() => { + cancellablePromise(manager.getStatus()).then(setManagerStatus); + return manager.onStatusChange(setManagerStatus); + }, [cancellablePromise, manager]); + + useEffect(() => { + cancellablePromise(software.getStatus()).then(setSoftwareStatus); + return software.onStatusChange(setSoftwareStatus); + }, [cancellablePromise, software]); + + const isLoading = managerStatus === BUSY || softwareStatus === BUSY; + + return ( + +
+

{selectedProduct.description}

+ +
+ {_("Registration code:")} + {registration.code} +
+
+ {_("Email:")} + {registration.email} +
+ + } + /> + +
+
+ ); +} diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx new file mode 100644 index 0000000000..4443436266 --- /dev/null +++ b/web/src/components/product/ProductPage.test.jsx @@ -0,0 +1,382 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { act, screen, within } from "@testing-library/react"; + +import { BUSY } from "~/client/status"; +import { installerRender } from "~/test-utils"; +import { ProductPage } from "~/components/product"; +import { createClient } from "~/client"; + +let mockManager; +let mockSoftware; +let mockRegistration; + +const products = [ + { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" + }, + { + id: "Test-Product2", + name: "Test Product2", + description: "Test Product2 description" + } +]; + +const selectedProduct = { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" +}; + +jest.mock("~/client"); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ products, selectedProduct, registration: mockRegistration }) +})); + +beforeEach(() => { + mockManager = { + startProbing: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn() + }; + + mockSoftware = { + probe: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn(), + product: { + getSelected: selectedProduct.id, + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } + }; + + mockRegistration = { + requirement: "not-required", + code: null, + email: null + }; + + createClient.mockImplementation(() => ( + { + manager: mockManager, + software: mockSoftware + } + )); +}); + +it("renders the product name and description", async () => { + installerRender(); + await screen.findByText("Test Product1"); + await screen.findByText("Test Product1 description"); +}); + +describe("if the product is already registered", () => { + beforeEach(() => { + mockRegistration.code = "111222"; + mockRegistration.email = "test@test.com"; + }); + + it("shows the information about the registration", async () => { + installerRender(); + await screen.findByText("111222"); + await screen.findByText("test@test.com"); + }); +}); + +describe("if the product does not require registration", () => { + beforeEach(() => { + mockRegistration.requirement = "not-required"; + }); + + it("shows a button to change the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Change product" }); + }); + + it("does not show a button to register the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Register" })).not.toBeInTheDocument(); + }); +}); + +describe("if the product requires registration", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + }); + + it("shows a button to change the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Change product" }); + }); + + describe("and the product is not registered yet", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("shows a button to register the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Register" }); + }); + }); + + describe("and the product is already registered", () => { + beforeEach(() => { + mockRegistration.code = "11112222"; + }); + + it("shows a button to deregister the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Deregister" }); + }); + }); +}); + +describe("when the services are busy", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + mockRegistration.code = null; + mockSoftware.getStatus = jest.fn().mockResolvedValue(BUSY); + }); + + it("shows disabled buttons", async () => { + await act(async () => installerRender()); + + const selectButton = await screen.findByRole("button", { name: "Change product" }); + const registerButton = screen.getByRole("button", { name: "Register" }); + + expect(selectButton).toHaveAttribute("disabled"); + expect(registerButton).toHaveAttribute("disabled"); + }); + + it("shows a message about configuring product", async () => { + await act(async () => installerRender()); + + screen.getByText(/Actions are disabled until the product is configured/); + }); +}); + +describe("when the button for changing the product is clicked", () => { + describe("and the product is not registered", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("opens a popup for selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Choose a product"); + within(popup).getByRole("radio", { name: "Test Product1" }); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.select).toHaveBeenCalledWith("Test-Product2"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.select).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); + + describe("and the product is registered", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + }); + + it("shows a warning", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText(/must be deregistered/); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); + +describe("when the button for registering the product is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = null; + mockSoftware.product.register = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup for registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + const emailInput = within(popup).getByLabelText("Email"); + + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.register).toHaveBeenCalledWith("111222", "test@test.com"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.register).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error registering the product", () => { + beforeEach(() => { + mockSoftware.product.register = jest.fn().mockResolvedValue({ + success: false, + message: "Error registering product" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + + await user.type(codeInput, "111222"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Error registering product"); + }); + }); +}); + +describe("when the button to perform product de-registration is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup to deregister the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Deregister Test Product1"); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.deregister).toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without performing product de-registration", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.deregister).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error performing the product de-registration", () => { + beforeEach(() => { + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ + success: false, + message: "Product cannot be deregistered" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Product cannot be deregistered"); + }); + }); +}); diff --git a/web/src/components/product/ProductRegistrationForm.jsx b/web/src/components/product/ProductRegistrationForm.jsx new file mode 100644 index 0000000000..2d0fd5efb5 --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.jsx @@ -0,0 +1,76 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { Form, FormGroup, TextInput } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { EmailInput } from "~/components/core"; +import { noop } from "~/utils"; + +/** + * Form for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.id - Form id. + * @param {function} props.onSubmit - Callback to be called when the form is submitted. + * @param {(isValid: boolean) => void} props.onValidate - Callback to be called when the form is + * validated. + */ +export default function ProductRegistrationForm({ + id, + onSubmit: onSubmitProp = noop, + onValidate = noop +}) { + const [code, setCode] = useState(""); + const [email, setEmail] = useState(""); + const [isValidEmail, setIsValidEmail] = useState(true); + + const onSubmit = (e) => { + e.preventDefault(); + onSubmitProp({ code, email }); + }; + + useEffect(() => { + const validate = () => { + return code.length > 0 && isValidEmail; + }; + + onValidate(validate()); + }, [code, isValidEmail, onValidate]); + + return ( +
+ + setCode(v)} /> + + + setEmail(v)} + /> + +
+ ); +} diff --git a/web/src/components/product/ProductRegistrationForm.test.jsx b/web/src/components/product/ProductRegistrationForm.test.jsx new file mode 100644 index 0000000000..14a9d18c4a --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.test.jsx @@ -0,0 +1,93 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { Button } from "@patternfly/react-core"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProductRegistrationForm } from "~/components/product"; + +it("renders a field for entering the registration code", async() => { + plainRender(); + await screen.findByLabelText(/Registration code/); +}); + +it("renders a field for entering an email", async() => { + plainRender(); + await screen.findByLabelText("Email"); +}); + +const ProductRegistrationFormTest = () => { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isValid, setIsValid] = useState(true); + + return ( + <> + + + {isSubmitted &&

Form is submitted!

} + {isValid === false &&

Form is not valid!

} + + ); +}; + +it("triggers the onSubmit callback", async () => { + const { user } = plainRender(); + + expect(screen.queryByText("Form is submitted!")).toBeNull(); + + const button = screen.getByRole("button", { name: "Accept" }); + await user.click(button); + await screen.findByText("Form is submitted!"); +}); + +it("sets the form as invalid if there is no code", async () => { + plainRender(); + await screen.findByText("Form is not valid!"); +}); + +it("sets the form as invalid if there is a code and a wrong email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "foo"); + + await screen.findByText("Form is not valid!"); +}); + +it("does not set the form as invalid if there is a code and no email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + await user.type(codeInput, "111222"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); + +it("does not set the form as invalid if there is a code and a correct email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); diff --git a/web/src/components/product/index.js b/web/src/components/product/index.js index 12e0a814b0..be115a18c8 100644 --- a/web/src/components/product/index.js +++ b/web/src/components/product/index.js @@ -19,5 +19,7 @@ * find current contact information at www.suse.com. */ +export { default as ProductPage } from "./ProductPage"; +export { default as ProductRegistrationForm } from "./ProductRegistrationForm"; export { default as ProductSelectionPage } from "./ProductSelectionPage"; export { default as ProductSelector } from "./ProductSelector"; diff --git a/web/src/index.js b/web/src/index.js index f47f5a76b5..53d58610a3 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -37,7 +37,7 @@ import App from "~/App"; import Main from "~/Main"; import DevServerWrapper from "~/DevServerWrapper"; import { Overview } from "~/components/overview"; -import { ProductSelectionPage } from "~/components/product"; +import { ProductPage, ProductSelectionPage } from "~/components/product"; import { SoftwarePage } from "~/components/software"; import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage"; import { UsersPage } from "~/components/users"; @@ -77,6 +77,7 @@ root.render( }> } /> } /> + } /> } /> } /> } /> From 33cf3a669f144e020b4d27e35d60f554c0238959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 09:55:53 +0000 Subject: [PATCH 86/97] [service] Do not automatically probe software after registration --- service/lib/agama/dbus/software/product.rb | 8 ++++++++ service/lib/agama/software/manager.rb | 11 +---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index 5085e42444..6c94418a9d 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -142,6 +142,10 @@ def requirement # Tries to register with the given registration code. # + # @note Software is not automatically probed after registering the product. The reason is + # to avoid dealing with possible probing issues in the registration D-Bus API. Clients + # have to explicitly call to #Probe after registering a product. + # # @param reg_code [String] # @param email [String, nil] # @@ -174,6 +178,10 @@ def register(reg_code, email: nil) # Tries to deregister. # + # @note Software is not automatically probed after deregistering the product. The reason is + # to avoid dealing with possible probing issues in the deregistration D-Bus API. Clients + # have to explicitly call to #Probe after deregistering a product. + # # @return [Array(Integer, String)] Result code and a description. # Possible result codes: # 0: success diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index cb398b00dc..be47087609 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -286,16 +286,7 @@ def used_disk_space end def registration - return @registration if @registration - - @registration = Registration.new(self, @logger) - @registration.on_change do - # reprobe and repropose when system is register or deregistered - probe - proposal - end - - @registration + @registration ||= Registration.new(self, logger) end # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 From 298dd3cd1e3326929690b3eeeb7ab70f97b00163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 11:20:06 +0000 Subject: [PATCH 87/97] [web] Fix tests --- web/src/components/core/IssuesLink.test.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/components/core/IssuesLink.test.jsx b/web/src/components/core/IssuesLink.test.jsx index dc2fdae1ba..8073276beb 100644 --- a/web/src/components/core/IssuesLink.test.jsx +++ b/web/src/components/core/IssuesLink.test.jsx @@ -25,17 +25,15 @@ import { installerRender, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesLink } from "~/components/core"; -let hasIssues = false; +let mockIssues = {}; jest.mock("~/client"); beforeEach(() => { createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -48,7 +46,9 @@ it("renders a link for navigating to the issues page", async () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + storage: [{ description: "issue 1" }] + }; }); it("includes a notification mark", async () => { @@ -60,7 +60,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = {}; }); it("does not include a notification mark", async () => { From 310ce7146ceab6bd172478919b0898e77516ecf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 3 Nov 2023 16:09:00 +0000 Subject: [PATCH 88/97] [web] Remove link for changing the product --- web/src/App.jsx | 2 - web/src/components/core/Sidebar.test.jsx | 1 - .../components/software/ChangeProductLink.jsx | 39 ----------- .../software/ChangeProductLink.test.jsx | 69 ------------------- web/src/components/software/index.js | 1 - 5 files changed, 112 deletions(-) delete mode 100644 web/src/components/software/ChangeProductLink.jsx delete mode 100644 web/src/components/software/ChangeProductLink.test.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index 0e162ca045..4099afcd10 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -39,7 +39,6 @@ import { ShowTerminalButton, Sidebar } from "~/components/core"; -import { ChangeProductLink } from "~/components/software"; import { LanguageSwitcher } from "./components/l10n"; import { Layout, Loading, Title } from "./components/layout"; import { useL10n } from "./context/l10n"; @@ -107,7 +106,6 @@ function App() { <>
- diff --git a/web/src/components/core/Sidebar.test.jsx b/web/src/components/core/Sidebar.test.jsx index 44ef3b4357..dce362f2fd 100644 --- a/web/src/components/core/Sidebar.test.jsx +++ b/web/src/components/core/Sidebar.test.jsx @@ -27,7 +27,6 @@ import { createClient } from "~/client"; // Mock some components using contexts and not relevant for below tests jest.mock("~/components/core/LogsButton", () => () =>
LogsButton Mock
); -jest.mock("~/components/software/ChangeProductLink", () => () =>
ChangeProductLink Mock
); let mockIssues; diff --git a/web/src/components/software/ChangeProductLink.jsx b/web/src/components/software/ChangeProductLink.jsx deleted file mode 100644 index 3eb872231a..0000000000 --- a/web/src/components/software/ChangeProductLink.jsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { Link } from "react-router-dom"; -import { useProduct } from "~/context/product"; -import { Icon } from "~/components/layout"; -import { _ } from "~/i18n"; - -export default function ChangeProductLink() { - const { products } = useProduct(); - - if (products?.length === 1) return null; - - return ( - - - {_("Change product")} - - ); -} diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx deleted file mode 100644 index 55afa5e24b..0000000000 --- a/web/src/components/software/ChangeProductLink.test.jsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, waitFor } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { ChangeProductLink } from "~/components/software"; - -let mockProducts; - -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), - useProduct: () => { - return { - products: mockProducts, - }; - } -})); - -describe("ChangeProductLink", () => { - describe("when there is only a single product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" } - ]; - }); - - it("renders nothing", async () => { - installerRender(); - - const main = await screen.findByRole("main"); - await waitFor(() => expect(main).toBeEmptyDOMElement()); - }); - }); - - describe("when there is more than one product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" }, - { id: "Leap Micro", name: "openSUSE Micro" } - ]; - }); - - it("renders a link for navigating to the selection product page", async () => { - installerRender(); - const link = await screen.findByRole("link", { name: "Change product" }); - - expect(link).toHaveAttribute("href", "/products"); - }); - }); -}); diff --git a/web/src/components/software/index.js b/web/src/components/software/index.js index 84f7a58f05..af42a2eb9d 100644 --- a/web/src/components/software/index.js +++ b/web/src/components/software/index.js @@ -19,7 +19,6 @@ * find current contact information at www.suse.com. */ -export { default as ChangeProductLink } from "./ChangeProductLink"; export { default as PatternSelector } from "./PatternSelector"; export { default as UsedSize } from "./UsedSize"; export { default as SoftwarePage } from "./SoftwarePage"; From 608cd40751df9c0dc6f2ddb26d1f6079c08d8f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Nov 2023 14:30:19 +0000 Subject: [PATCH 89/97] [web] Add registration section to product page --- web/src/assets/styles/utilities.scss | 4 + web/src/components/product/ProductPage.jsx | 212 +++++++++--------- .../components/product/ProductPage.test.jsx | 54 +++-- 3 files changed, 142 insertions(+), 128 deletions(-) diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index 6b64d2f47b..1f84016877 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -142,6 +142,10 @@ padding: 0; } +.p-0 { + padding: 0; +} + .no-stack-gutter { --stack-gutter: 0; } diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx index 4814f33231..77c671f855 100644 --- a/web/src/components/product/ProductPage.jsx +++ b/web/src/components/product/ProductPage.jsx @@ -42,7 +42,7 @@ import { useProduct } from "~/context/product"; * @param {function} props.onFinish - Callback to be called when the product is correctly selected. * @param {function} props.onCancel - Callback to be called when the product selection is canceled. */ -const ProductSelectionPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => { +const ChangeProductPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => { const { manager, software } = useInstallerClient(); const { products, selectedProduct } = useProduct(); const [newProductId, setNewProductId] = useState(selectedProduct?.id); @@ -87,7 +87,7 @@ const ProductSelectionPopup = ({ isOpen = false, onFinish = noop, onCancel = noo * @param {function} props.onCancel - Callback to be called when the product registration is * canceled. */ -const ProductRegistrationPopup = ({ +const RegisterProductPopup = ({ isOpen = false, onFinish = noop, onCancel: onCancelProp = noop @@ -156,7 +156,7 @@ const ProductRegistrationPopup = ({ * @param {function} props.onCancel - Callback to be called when the product de-registration is * canceled. */ -const ProductDeregistrationPopup = ({ +const DeregisterProductPopup = ({ isOpen = false, onFinish = noop, onCancel: onCancelProp = noop @@ -197,7 +197,10 @@ const ProductDeregistrationPopup = ({ } />

- {sprintf(_("Do you want to deregister %s?"), selectedProduct.name)} + { + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) + sprintf(_("Do you want to deregister %s?"), selectedProduct.name) + }

@@ -225,6 +228,7 @@ const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => {

{ sprintf( + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) _("The product %s must be deregistered before selecting a new product."), selectedProduct.name ) @@ -239,29 +243,40 @@ const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => { ); }; -/** - * Buttons for a product that does not require registration. - * @component - * - * @param {object} props - * @param {boolean} props.isDisabled - */ -const WithoutRegistrationButtons = ({ isDisabled = false }) => { +const ChangeProductButton = ({ isDisabled = false }) => { const [isPopupOpen, setIsPopupOpen] = useState(false); + const { registration } = useProduct(); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + const isRegistered = registration.code !== null; return ( <> - setIsPopupOpen(false)} - onCancel={() => setIsPopupOpen(false)} + + } + else={ + + } /> ); @@ -274,115 +289,122 @@ const WithoutRegistrationButtons = ({ isDisabled = false }) => { * @param {object} props * @param {boolean} props.isDisabled */ -const DeregisteredButtons = ({ isDisabled = false }) => { - const [isRegistrationPopupOpen, setIsRegistrationPopupOpen] = useState(false); - const [isSelectionPopupOpen, setIsSelectionPopupOpen] = useState(false); +const RegisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); return ( <> - - setIsRegistrationPopupOpen(false)} - onCancel={() => setIsRegistrationPopupOpen(false)} - /> - setIsSelectionPopupOpen(false)} - onCancel={() => setIsSelectionPopupOpen(false)} + ); }; /** - * Buttons for a product that is already registered. + * Buttons for a product that is not registered yet. * @component * * @param {object} props * @param {boolean} props.isDisabled */ -const RegisteredButtons = ({ isDisabled = false }) => { - const [isDeregistrationPopupOpen, setIsDeregistrationPopupOpen] = useState(false); - const [isWarningPopupOpen, setIsWarningPopupOpen] = useState(false); +const DeregisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); return ( <> - - setIsDeregistrationPopupOpen(false)} - onCancel={() => setIsDeregistrationPopupOpen(false)} - /> - setIsWarningPopupOpen(false)} + ); }; -/** - * Renders the actions for the current product. - * @component - * - * @param {object} props - * @param {boolean} props.isDisabled - */ -const ProductActions = ({ isDisabled = false }) => { +const ProductSection = ({ isLoading = false }) => { + const { products, selectedProduct } = useProduct(); + + return ( +

+

{selectedProduct?.description}

+ 1} + then={} + /> +
+ ); +}; + +const RegistrationContent = ({ isLoading = false }) => { const { registration } = useProduct(); - const withRegistration = registration.requirement !== "not-required"; - const registered = registration.code !== null; + const mask = (v) => v.replace(v.slice(0, -4), "*".repeat(Math.max(v.length - 4, 0))); return ( <>
- } - else={} - /> - } - else={} - /> + {_("Code:")} + {mask(registration.code)}
+
+ {_("Email:")} + {registration.email} +
+ + + ); +}; + +const RegistrationSection = ({ isLoading = false }) => { + const { registration } = useProduct(); + + const isRequired = registration?.requirement !== "not-required"; + const isRegistered = registration?.code !== null; + + return ( + // TRANSLATORS: section title. +
{_("Configuring product. Actions are disabled until the product is configured.")}

+ } + else={ + <> +

{_("This product requires registration.")}

+ + + } + /> } + else={

{_("This product does not require registration.")}

} /> - +
); }; @@ -395,7 +417,6 @@ export default function ProductPage() { const [softwareStatus, setSoftwareStatus] = useState(); const { cancellablePromise } = useCancellablePromise(); const { manager, software } = useInstallerClient(); - const { selectedProduct, registration } = useProduct(); useEffect(() => { cancellablePromise(manager.getStatus()).then(setManagerStatus); @@ -411,25 +432,8 @@ export default function ProductPage() { return ( -
-

{selectedProduct.description}

- -
- {_("Registration code:")} - {registration.code} -
-
- {_("Email:")} - {registration.email} -
- - } - /> - -
+ +
); } diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx index 4443436266..8b46818a5c 100644 --- a/web/src/components/product/ProductPage.test.jsx +++ b/web/src/components/product/ProductPage.test.jsx @@ -29,6 +29,7 @@ import { createClient } from "~/client"; let mockManager; let mockSoftware; +let mockProducts; let mockRegistration; const products = [ @@ -54,7 +55,7 @@ jest.mock("~/client"); jest.mock("~/context/product", () => ({ ...jest.requireActual("~/context/product"), - useProduct: () => ({ products, selectedProduct, registration: mockRegistration }) + useProduct: () => ({ products: mockProducts, selectedProduct, registration: mockRegistration }) })); beforeEach(() => { @@ -75,6 +76,8 @@ beforeEach(() => { } }; + mockProducts = products; + mockRegistration = { requirement: "not-required", code: null, @@ -95,15 +98,34 @@ it("renders the product name and description", async () => { await screen.findByText("Test Product1 description"); }); +it("shows a button to change the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Change product" }); +}); + +describe("if there is only a product", () => { + beforeEach(() => { + mockProducts = [products[0]]; + }); + + it("does not show a button to change the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Change product" })).not.toBeInTheDocument(); + }); +}); + describe("if the product is already registered", () => { beforeEach(() => { - mockRegistration.code = "111222"; - mockRegistration.email = "test@test.com"; + mockRegistration = { + requirement: "mandatory", + code: "111222", + email: "test@test.com" + }; }); it("shows the information about the registration", async () => { installerRender(); - await screen.findByText("111222"); + await screen.findByText("**1222"); await screen.findByText("test@test.com"); }); }); @@ -113,11 +135,6 @@ describe("if the product does not require registration", () => { mockRegistration.requirement = "not-required"; }); - it("shows a button to change the product", async () => { - installerRender(); - await screen.findByRole("button", { name: "Change product" }); - }); - it("does not show a button to register the product", async () => { installerRender(); expect(screen.queryByRole("button", { name: "Register" })).not.toBeInTheDocument(); @@ -129,11 +146,6 @@ describe("if the product requires registration", () => { mockRegistration.requirement = "required"; }); - it("shows a button to change the product", async () => { - installerRender(); - await screen.findByRole("button", { name: "Change product" }); - }); - describe("and the product is not registered yet", () => { beforeEach(() => { mockRegistration.code = null; @@ -152,7 +164,7 @@ describe("if the product requires registration", () => { it("shows a button to deregister the product", async () => { installerRender(); - await screen.findByRole("button", { name: "Deregister" }); + await screen.findByRole("button", { name: "Deregister product" }); }); }); }); @@ -173,12 +185,6 @@ describe("when the services are busy", () => { expect(selectButton).toHaveAttribute("disabled"); expect(registerButton).toHaveAttribute("disabled"); }); - - it("shows a message about configuring product", async () => { - await act(async () => installerRender()); - - screen.getByText(/Actions are disabled until the product is configured/); - }); }); describe("when the button for changing the product is clicked", () => { @@ -329,7 +335,7 @@ describe("when the button to perform product de-registration is clicked", () => it("opens a popup to deregister the product", async () => { const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Deregister" }); + const button = screen.getByRole("button", { name: "Deregister product" }); await user.click(button); const popup = await screen.findByRole("dialog"); @@ -346,7 +352,7 @@ describe("when the button to perform product de-registration is clicked", () => it("closes the popup without performing product de-registration", async () => { const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Deregister" }); + const button = screen.getByRole("button", { name: "Deregister product" }); await user.click(button); const popup = await screen.findByRole("dialog"); @@ -369,7 +375,7 @@ describe("when the button to perform product de-registration is clicked", () => it("does not close the popup and shows the error", async () => { const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Deregister" }); + const button = screen.getByRole("button", { name: "Deregister product" }); await user.click(button); const popup = await screen.findByRole("dialog"); From 354805c3c0611b4bf8bf116b783a0958703faa61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Nov 2023 14:30:49 +0000 Subject: [PATCH 90/97] [web] Use password field for registration code --- web/src/components/product/ProductRegistrationForm.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/product/ProductRegistrationForm.jsx b/web/src/components/product/ProductRegistrationForm.jsx index 2d0fd5efb5..510f0c6c20 100644 --- a/web/src/components/product/ProductRegistrationForm.jsx +++ b/web/src/components/product/ProductRegistrationForm.jsx @@ -20,10 +20,10 @@ */ import React, { useEffect, useState } from "react"; -import { Form, FormGroup, TextInput } from "@patternfly/react-core"; +import { Form, FormGroup } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { EmailInput } from "~/components/core"; +import { EmailInput, PasswordInput } from "~/components/core"; import { noop } from "~/utils"; /** @@ -61,7 +61,7 @@ export default function ProductRegistrationForm({ return (
- setCode(v)} /> + setCode(v)} /> Date: Tue, 14 Nov 2023 14:31:47 +0000 Subject: [PATCH 91/97] [web] Improve product section --- .../components/overview/ProductSection.jsx | 34 +++++++++++-------- .../overview/ProductSection.test.jsx | 23 ++++++++++--- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx index 935d20ef16..83121fab21 100644 --- a/web/src/components/overview/ProductSection.jsx +++ b/web/src/components/overview/ProductSection.jsx @@ -21,10 +21,12 @@ import React, { useEffect, useState } from "react"; import { Text } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + import { toValidationError, useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; import { useProduct } from "~/context/product"; -import { If, Section, SectionSkeleton } from "~/components/core"; +import { Section, SectionSkeleton } from "~/components/core"; import { _ } from "~/i18n"; const errorsFrom = (issues) => { @@ -32,6 +34,22 @@ const errorsFrom = (issues) => { return errors.map(toValidationError); }; +const Content = ({ isLoading = false }) => { + const { registration, selectedProduct } = useProduct(); + + if (isLoading) return ; + + const isRegistered = registration?.code !== null; + const productName = selectedProduct?.name; + + return ( + + {/* TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) */} + {isRegistered ? sprintf(_("%s (registered)"), productName) : productName} + + ); +}; + export default function ProductSection() { const { software } = useInstallerClient(); const [issues, setIssues] = useState([]); @@ -43,20 +61,6 @@ export default function ProductSection() { return software.product.onIssuesChange(setIssues); }, [cancellablePromise, setIssues, software]); - const Content = ({ isLoading = false }) => { - return ( - } - else={ - - {selectedProduct?.name} - - } - /> - ); - }; - const isLoading = !selectedProduct; const errors = isLoading ? [] : errorsFrom(issues); diff --git a/web/src/components/overview/ProductSection.test.jsx b/web/src/components/overview/ProductSection.test.jsx index 66fa1c2f17..17edd0b62f 100644 --- a/web/src/components/overview/ProductSection.test.jsx +++ b/web/src/components/overview/ProductSection.test.jsx @@ -25,7 +25,8 @@ import { installerRender } from "~/test-utils"; import { createClient } from "~/client"; import { ProductSection } from "~/components/overview"; -let mockProduct; +let mockRegistration; +let mockSelectedProduct; const mockIssue = { severity: "error", description: "Fake issue" }; @@ -35,12 +36,16 @@ jest.mock("~/components/core/SectionSkeleton", () => () =>
Loading
); jest.mock("~/context/product", () => ({ ...jest.requireActual("~/context/product"), - useProduct: () => ({ selectedProduct: mockProduct }) + useProduct: () => ({ + registration: mockRegistration, + selectedProduct: mockSelectedProduct + }) })); beforeEach(() => { const issues = [mockIssue]; - mockProduct = { name: "Test Product" }; + mockRegistration = {}; + mockSelectedProduct = { name: "Test Product" }; createClient.mockImplementation(() => { return { @@ -57,7 +62,15 @@ beforeEach(() => { it("shows the product name", async () => { installerRender(); - await screen.findByText("Test Product"); + await screen.findByText(/Test Product/); + await waitFor(() => expect(screen.queryByText("registered")).not.toBeInTheDocument()); +}); + +it("indicates whether the product is registered", async () => { + mockRegistration = { code: "111222" }; + installerRender(); + + await screen.findByText(/Test Product \(registered\)/); }); it("shows the error", async () => { @@ -76,7 +89,7 @@ it("does not show warnings", async () => { describe("when no product is selected", () => { beforeEach(() => { - mockProduct = undefined; + mockSelectedProduct = undefined; }); it("shows the skeleton", async () => { From 0e6c2fd672612a51fe93da23e2eb2896c6702f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Nov 2023 14:44:24 +0000 Subject: [PATCH 92/97] [web] Several improvements from review --- web/src/client/index.js | 18 +++++++-------- web/src/client/software.js | 2 +- web/src/client/software.test.js | 4 ++-- web/src/components/core/EmailInput.test.jsx | 8 +++---- web/src/components/core/SectionSkeleton.jsx | 22 +++++++++---------- .../components/product/ProductPage.test.jsx | 4 ++-- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/web/src/client/index.js b/web/src/client/index.js index 96079edcc3..34a656972e 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -37,15 +37,15 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; /** * @typedef {object} InstallerClient - * @property {LanguageClient} language - language client - * @property {ManagerClient} manager - manager client - * @property {Monitor} monitor - service monitor - * @property {NetworkClient} network - network client - * @property {SoftwareClient} software - software client - * @property {StorageClient} storage - storage client - * @property {UsersClient} users - users client - * @property {QuestionsClient} questions - questions client - * @property {() => Promise} issues - issues from all contexts + * @property {LanguageClient} language - language client. + * @property {ManagerClient} manager - manager client. + * @property {Monitor} monitor - service monitor. + * @property {NetworkClient} network - network client. + * @property {SoftwareClient} software - software client. + * @property {StorageClient} storage - storage client. + * @property {UsersClient} users - users client. + * @property {QuestionsClient} questions - questions client. + * @property {() => Promise} issues - issues from all contexts. * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run * when issues from any context change. It returns a function to deregister the handler. * @property {() => Promise} isConnected - determines whether the client is connected diff --git a/web/src/client/software.js b/web/src/client/software.js index 2bb678446c..edec2fcc21 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -40,7 +40,7 @@ const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} Registration - * @property {string} requirement - Registration requirement (i.e., "not-required, "optional", + * @property {string} requirement - Registration requirement (i.e., "not-required", "optional", * "mandatory"). * @property {string|null} code - Registration code, if any. * @property {string|null} email - Registration email, if any. diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index 6c68a69b4e..dfcdaeaed7 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -75,14 +75,14 @@ describe("#product", () => { }); describe("#getRegistration", () => { - describe("if there the product is not registered yet", () => { + describe("if the product is not registered yet", () => { beforeEach(() => { registrationProxy.RegCode = ""; registrationProxy.Email = ""; registrationProxy.Requirement = 1; }); - it("returns the expected registration", async () => { + it("returns the expected registration result", async () => { const client = new SoftwareClient(); const registration = await client.product.getRegistration(); expect(registration).toStrictEqual({ diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.jsx index 5834ca8a21..0c64ce08b7 100644 --- a/web/src/components/core/EmailInput.test.jsx +++ b/web/src/components/core/EmailInput.test.jsx @@ -36,7 +36,7 @@ describe("EmailInput component", () => { /> ); - const inputField = screen.getByLabelText("User email"); + const inputField = screen.getByRole('textbox', { name: "User email" }); expect(inputField).toHaveAttribute("type", "email"); }); @@ -63,7 +63,7 @@ describe("EmailInput component", () => { it("triggers onChange callback", async () => { const { user } = plainRender(); - const emailInput = screen.getByLabelText("Test email"); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); expect(screen.queryByText("Email value updated!")).toBeNull(); @@ -73,7 +73,7 @@ describe("EmailInput component", () => { it("triggers onValidate callback", async () => { const { user } = plainRender(); - const emailInput = screen.getByLabelText("Test email"); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); expect(screen.queryByText("Email is not valid!")).toBeNull(); @@ -83,7 +83,7 @@ describe("EmailInput component", () => { it("marks the input as invalid if the value is not a valid email", async () => { const { user } = plainRender(); - const emailInput = screen.getByLabelText("Test email"); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); await user.type(emailInput, "foo"); diff --git a/web/src/components/core/SectionSkeleton.jsx b/web/src/components/core/SectionSkeleton.jsx index bc6cf20cda..e34da05402 100644 --- a/web/src/components/core/SectionSkeleton.jsx +++ b/web/src/components/core/SectionSkeleton.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -23,17 +23,17 @@ import React from "react"; import { Skeleton } from "@patternfly/react-core"; import { _ } from "~/i18n"; -const SectionSkeleton = ({ numRows = 2 }) => { - const WaitingSkeleton = ({ width }) => { - return ( - - ); - }; +const WaitingSkeleton = ({ width }) => { + return ( + + ); +}; +const SectionSkeleton = ({ numRows = 2 }) => { return ( <> { diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx index 8b46818a5c..af2531340a 100644 --- a/web/src/components/product/ProductPage.test.jsx +++ b/web/src/components/product/ProductPage.test.jsx @@ -20,7 +20,7 @@ */ import React from "react"; -import { act, screen, within } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import { BUSY } from "~/client/status"; import { installerRender } from "~/test-utils"; @@ -177,7 +177,7 @@ describe("when the services are busy", () => { }); it("shows disabled buttons", async () => { - await act(async () => installerRender()); + installerRender(); const selectButton = await screen.findByRole("button", { name: "Change product" }); const registerButton = screen.getByRole("button", { name: "Register" }); From e5e7510a04363373fbcfcbfc90abf4aad9630ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 11:23:33 +0000 Subject: [PATCH 93/97] [web] Use early return --- .../components/product/ProductSelector.jsx | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx index 05b2842bf6..311a6a7e89 100644 --- a/web/src/components/product/ProductSelector.jsx +++ b/web/src/components/product/ProductSelector.jsx @@ -23,32 +23,27 @@ import React from "react"; import { Card, CardBody, Radio } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { If } from "~/components/core"; import { noop } from "~/utils"; export default function ProductSelector({ value, products = [], onChange = noop }) { + if (products.length === 0) return

{_("No products available for selection")}

; + const isSelected = (product) => product.id === value; return ( - {_("No products available for selection")}

} - else={ - products.map((p) => ( - - - onChange(p.id)} - /> - - - )) - } - /> + products.map((p) => ( + + + onChange(p.id)} + /> + + + )) ); } From f6d5bfe0b98d69c9423a60a5178a2adbf7b112eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 11:24:00 +0000 Subject: [PATCH 94/97] [service] Remove repos from ALP-Dolomite config - The product has to be registered --- products.d/ALP-Dolomite.yaml | 10 ---------- service/test/agama/software/manager_test.rb | 4 ++-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/products.d/ALP-Dolomite.yaml b/products.d/ALP-Dolomite.yaml index 345c7a2461..c2d4e7d039 100644 --- a/products.d/ALP-Dolomite.yaml +++ b/products.d/ALP-Dolomite.yaml @@ -15,16 +15,6 @@ translations: bezpečnost pro poskytování úplného minima ke spuštění úloh a služeb v kontejnerech nebo virtuálních strojích. software: - installation_repositories: - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/ - archs: x86_64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/ - archs: aarch64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/s390x/product/ - archs: s390 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/ppc64le/product/ - archs: ppc - mandatory_patterns: - alp_base_zypper - alp_cockpit diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 77bec63e2a..f899ab64b3 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -191,7 +191,7 @@ stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir) stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir) FileUtils.mkdir_p(repos_dir) - subject.select_product("ALP-Dolomite") + subject.select_product("Tumbleweed") end after do @@ -218,7 +218,7 @@ end it "registers the repository from config" do - expect(repositories).to receive(:add).with(/Dolomite/) + expect(repositories).to receive(:add).with(/tumbleweed/) expect(repositories).to receive(:load) subject.probe end From 12d4ba49937803aef002caa78bf7bfc5a7a6029c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 12:34:26 +0000 Subject: [PATCH 95/97] [service] Changelog --- service/package/rubygem-agama.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/service/package/rubygem-agama.changes b/service/package/rubygem-agama.changes index 3246d84103..882ca7c2d7 100644 --- a/service/package/rubygem-agama.changes +++ b/service/package/rubygem-agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Nov 15 12:31:10 UTC 2023 - José Iván López González + +- Add D-Bus API for registering a product (gh#openSUSE/agama#869). + ------------------------------------------------------------------- Thu Nov 2 14:00:01 UTC 2023 - Ancor Gonzalez Sosa From acc1cf2ae7a9e98e9a37e8f3e4eb822e55790499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 12:34:43 +0000 Subject: [PATCH 96/97] [web] Changelog --- web/package/cockpit-agama.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index f510116a81..d9bdc468d7 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Nov 15 12:32:25 UTC 2023 - José Iván López González + +- Add UI for registering a product (gh#openSUSE/agama#869). + ------------------------------------------------------------------- Thu Nov 2 07:38:22 UTC 2023 - David Diaz From 29665b1138f7d07a2fdaa1f8612f717be114a769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Nov 2023 12:37:00 +0000 Subject: [PATCH 97/97] [rust] Changelog --- rust/package/agama-cli.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes index 1dd3d8414b..bd24b544d3 100644 --- a/rust/package/agama-cli.changes +++ b/rust/package/agama-cli.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Nov 15 12:35:32 UTC 2023 - José Iván López González + +- Adapt to changes in software D-Bus API (gh#openSUSE/agama#869). + ------------------------------------------------------------------- Mon Oct 23 14:43:59 UTC 2023 - Michal Filka