From 2c4ff02df67827906ba36545ddcba2bba00243a9 Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Tue, 6 Dec 2022 11:16:58 -0500 Subject: [PATCH 1/6] Call after_update_* from edit_with_params When changes are made to the endpoints or authentications workers which run with an open connection like event catchers and streaming refresh workers have to be restarted. This was done via `after_update_authentication` called from `update_authentication`. The issue is that this method is no longer called when providers are updated via the API with DDF parameters. --- app/models/ext_management_system.rb | 22 +++- spec/models/ext_management_system_spec.rb | 145 ++++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/app/models/ext_management_system.rb b/app/models/ext_management_system.rb index faf46c6ca29..ab47b028a13 100644 --- a/app/models/ext_management_system.rb +++ b/app/models/ext_management_system.rb @@ -176,18 +176,30 @@ def self.create_from_params(params, endpoints, authentications) def edit_with_params(params, endpoints, authentications) tap do |ems| + endpoints_changed = false + authentications_changed = false + transaction do # Remove endpoints/attributes that are not arriving in the arguments above - ems.endpoints.where.not(:role => nil).where.not(:role => endpoints.map { |ep| ep['role'] }).delete_all - ems.authentications.where.not(:authtype => nil).where.not(:authtype => authentications.map { |au| au['authtype'] }).delete_all + endpoints_to_delete = ems.endpoints.where.not(:role => nil).where.not(:role => endpoints.map { |ep| ep['role'] }) + authentications_to_delete = ems.authentications.where.not(:authtype => nil).where.not(:authtype => authentications.map { |au| au['authtype'] }) + + endpoints_changed ||= endpoints_to_delete.delete_all > 0 + authentications_changed ||= authentications_to_delete.delete_all > 0 ems.assign_attributes(params) - ems.endpoints = endpoints.map(&method(:assign_nested_endpoint)) + ems.endpoints = endpoints.map(&method(:assign_nested_endpoint)) ems.authentications = authentications.map(&method(:assign_nested_authentication)) + endpoints_changed ||= ems.endpoints.any?(&:changed?) + authentications_changed ||= ems.authentications.any?(&:changed?) + ems.provider.save! if ems.provider.present? && ems.provider.changed? ems.save! end + + after_update_endpoints if endpoints_changed + after_update_authentication if authentications_changed end end @@ -838,6 +850,10 @@ def after_update_authentication stop_event_monitor_queue_on_credential_change end + def after_update_endpoints + stop_event_monitor_queue_on_change + end + ################################### # Event Monitor ################################### diff --git a/spec/models/ext_management_system_spec.rb b/spec/models/ext_management_system_spec.rb index ccfeac5db96..b4950e32507 100644 --- a/spec/models/ext_management_system_spec.rb +++ b/spec/models/ext_management_system_spec.rb @@ -833,6 +833,151 @@ def deliver_queue_message(queue_message = MiqQueue.order(:id).first) end end + describe "#edit_with_params" do + let(:zone) { EvmSpecHelper.local_miq_server.zone } + let(:ems) do + FactoryBot.create(:ext_management_system, :with_authentication, :zone => zone).tap do |ems| + # assign_nested_authentication automatically sets the name to "#{self.class.name} #{name}" + # set it here to prevent spurious authentication record changes + ems.default_authentication.update!(:name => "#{ems.class.name} #{ems.name}") + end + end + + let(:params) { {"name" => ems.name, "zone" => ems.zone} } + let(:endpoints) { [default_endpoint] } + let(:authentications) { [default_authentication] } + + let(:default_endpoint) { {"role" => "default", "hostname" => ems.default_endpoint.hostname, "ipaddress" => ems.default_endpoint.ipaddress} } + let(:default_authentication) { {"authtype" => "default", "userid" => ems.default_authentication.userid, "password" => ems.default_authentication.password} } + + context "with no changes" do + it "doesn't call endpoints or authentications changed callbaks" do + expect(ems).not_to receive(:after_update_endpoints) + expect(ems).not_to receive(:after_update_authentication) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "changing an ext_management_system record attribute" do + let(:params) { {:name => "new-name", :zone => ems.zone} } + + it "changes the name" do + ems.edit_with_params(params, endpoints, authentications) + expect(ems.reload.name).to eq("new-name") + end + end + + context "adding an endpoint" do + let(:endpoints) { [default_endpoint, {"role" => "metrics", "hostname" => "metrics"}] } + + it "creates the new endpoint" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + + expect(ems.endpoints.pluck(:role)).to match_array(["default", "metrics"]) + end + + it "calls after_update_endpoints" do + expect(ems).to receive(:after_update_endpoints) + expect(ems).not_to receive(:after_update_authentication) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "deleting an endpoint" do + before { ems.endpoints.create!(:role => "metrics", :hostname => "metrics") } + + it "deletes the unused endpoint" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + expect(ems.endpoints.pluck(:role)).to match_array(["default"]) + end + + it "calls after_update_endpoints" do + expect(ems).to receive(:after_update_endpoints) + expect(ems).not_to receive(:after_update_authentication) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "updating an endpoint" do + let(:default_endpoint) { {"role" => "default", "hostname" => "new-hostname", "ipaddress" => ems.default_endpoint.ipaddress} } + + it "updates the default hostname" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + expect(ems.default_endpoint.hostname).to eq("new-hostname") + end + + it "calls after_update_endpoints" do + expect(ems).to receive(:after_update_endpoints) + expect(ems).not_to receive(:after_update_authentication) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "adding an authentication" do + let(:authentications) { [default_authentication, {:authtype => "metrics", :auth_key => "secret"}] } + + it "creates the authentication" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + expect(ems.authentications.pluck(:authtype)).to match_array(["default", "metrics"]) + end + + it "calls after_update_authentication" do + expect(ems).to receive(:after_update_authentication) + expect(ems).not_to receive(:after_update_endpoints) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "deleting an authentication" do + before { ems.authentications.create!(:authtype => "metrics", :auth_key => "secret") } + + it "deletes the authentication" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + expect(ems.authentications.pluck(:authtype)).to match_array(["default"]) + end + + it "calls after_update_authentication" do + expect(ems).to receive(:after_update_authentication) + expect(ems).not_to receive(:after_update_endpoints) + + ems.edit_with_params(params, endpoints, authentications) + end + end + + context "updating an authentication" do + let(:default_authentication) { {"authtype" => "default", "userid" => ems.default_authentication.userid, "password" => "more-secret"} } + + it "updates the password" do + ems.edit_with_params(params, endpoints, authentications) + + ems.reload + expect(ems.default_authentication.password).to eq("more-secret") + end + + it "calls after_update_authentication" do + expect(ems).to receive(:after_update_authentication) + expect(ems).not_to receive(:after_update_endpoints) + + ems.edit_with_params(params, endpoints, authentications) + end + end + end + context "virtual column :supports_block_storage (direct supports)" do it "returns false if block storage is not supported" do ems = FactoryBot.create(:ext_management_system) From 8ad2e53bcf45ba52dc89ce46f6c7a5d9c337e1bf Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Tue, 6 Dec 2022 13:52:17 -0500 Subject: [PATCH 2/6] Move callbacks before save for `#changed?` and `#changes?` --- app/models/ext_management_system.rb | 14 +++++++------- spec/models/ext_management_system_spec.rb | 14 +++++++++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/models/ext_management_system.rb b/app/models/ext_management_system.rb index ab47b028a13..0030d049c06 100644 --- a/app/models/ext_management_system.rb +++ b/app/models/ext_management_system.rb @@ -194,12 +194,12 @@ def edit_with_params(params, endpoints, authentications) endpoints_changed ||= ems.endpoints.any?(&:changed?) authentications_changed ||= ems.authentications.any?(&:changed?) + after_update_endpoints if endpoints_changed + after_update_authentication if authentications_changed + ems.provider.save! if ems.provider.present? && ems.provider.changed? ems.save! end - - after_update_endpoints if endpoints_changed - after_update_authentication if authentications_changed end end @@ -891,14 +891,14 @@ def stop_event_monitor_queue end def stop_event_monitor_queue_on_change - if event_monitor_class && !self.new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") + if event_monitor_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Hostname or IP address has changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end end def stop_event_monitor_queue_on_credential_change - if event_monitor_class && !self.new_record? && self.credentials_changed? + if event_monitor_class && !new_record? && default_authentication&.changed? _log.info("EMS: [#{name}], Credentials have changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end @@ -955,14 +955,14 @@ def stop_refresh_worker_queue end def stop_refresh_worker_queue_on_change - if refresh_worker_class && !self.new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") + if refresh_worker_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Hostname or IP address has changed, stopping Refresh Worker. It will be restarted by the WorkerMonitor.") stop_refresh_worker_queue end end def stop_refresh_worker_queue_on_credential_change - if refresh_worker_class && !self.new_record? && self.credentials_changed? + if refresh_worker_class && !new_record? && default_authentication&.changed? _log.info("EMS: [#{name}], Credentials have changed, stopping Refresh Worker. It will be restarted by the WorkerMonitor.") stop_refresh_worker_queue end diff --git a/spec/models/ext_management_system_spec.rb b/spec/models/ext_management_system_spec.rb index b4950e32507..1e91e529907 100644 --- a/spec/models/ext_management_system_spec.rb +++ b/spec/models/ext_management_system_spec.rb @@ -851,7 +851,7 @@ def deliver_queue_message(queue_message = MiqQueue.order(:id).first) let(:default_authentication) { {"authtype" => "default", "userid" => ems.default_authentication.userid, "password" => ems.default_authentication.password} } context "with no changes" do - it "doesn't call endpoints or authentications changed callbaks" do + it "doesn't call endpoints or authentications changed callbacks" do expect(ems).not_to receive(:after_update_endpoints) expect(ems).not_to receive(:after_update_authentication) @@ -921,6 +921,12 @@ def deliver_queue_message(queue_message = MiqQueue.order(:id).first) ems.edit_with_params(params, endpoints, authentications) end + + it "stops the event monitor" do + expect(ems).to receive(:stop_event_monitor_queue) + + ems.edit_with_params(params, endpoints, authentications) + end end context "adding an authentication" do @@ -975,6 +981,12 @@ def deliver_queue_message(queue_message = MiqQueue.order(:id).first) ems.edit_with_params(params, endpoints, authentications) end + + it "stops the event monitor" do + expect(ems).to receive(:stop_event_monitor_queue) + + ems.edit_with_params(params, endpoints, authentications) + end end end From e250c8142bd0524b7ab3427a13575752478d90d9 Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Wed, 7 Dec 2022 10:38:01 -0500 Subject: [PATCH 3/6] Remove last caller of credentials_changed? --- app/models/manageiq/providers/cloud_manager.rb | 2 +- app/models/mixins/authentication_mixin.rb | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/app/models/manageiq/providers/cloud_manager.rb b/app/models/manageiq/providers/cloud_manager.rb index 3204330f937..82b42c2747c 100644 --- a/app/models/manageiq/providers/cloud_manager.rb +++ b/app/models/manageiq/providers/cloud_manager.rb @@ -77,7 +77,7 @@ def open_browser end def stop_event_monitor_queue_on_credential_change - if event_monitor_class && !self.new_record? && self.credentials_changed? + if event_monitor_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Credentials have changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue network_manager.stop_event_monitor_queue if respond_to?(:network_manager) && network_manager diff --git a/app/models/mixins/authentication_mixin.rb b/app/models/mixins/authentication_mixin.rb index fae8e8c2788..df7c1bddfb7 100644 --- a/app/models/mixins/authentication_mixin.rb +++ b/app/models/mixins/authentication_mixin.rb @@ -183,8 +183,6 @@ def update_authentication(data, options = {}) options.reverse_merge!(:save => true) - @orig_credentials ||= auth_user_pwd || "none" - # Invoke before callback before_update_authentication if self.respond_to?(:before_update_authentication) && options[:save] @@ -250,13 +248,6 @@ def update_authentication(data, options = {}) # Invoke callback after_update_authentication if self.respond_to?(:after_update_authentication) && options[:save] - @orig_credentials = nil if options[:save] - end - - def credentials_changed? - @orig_credentials ||= auth_user_pwd || "none" - new_credentials = auth_user_pwd || "none" - @orig_credentials != new_credentials end def authentication_type(type) From 6a43cb9d2fb3ea1ffd84f8732b66129c2c12cb1b Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Wed, 7 Dec 2022 11:06:56 -0500 Subject: [PATCH 4/6] Only call after_update_* when something changed --- app/models/ext_management_system.rb | 10 +++++----- app/models/mixins/authentication_mixin.rb | 12 +++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/models/ext_management_system.rb b/app/models/ext_management_system.rb index 0030d049c06..4e125163948 100644 --- a/app/models/ext_management_system.rb +++ b/app/models/ext_management_system.rb @@ -194,12 +194,12 @@ def edit_with_params(params, endpoints, authentications) endpoints_changed ||= ems.endpoints.any?(&:changed?) authentications_changed ||= ems.authentications.any?(&:changed?) - after_update_endpoints if endpoints_changed - after_update_authentication if authentications_changed - ems.provider.save! if ems.provider.present? && ems.provider.changed? ems.save! end + + after_update_endpoints if endpoints_changed + after_update_authentication if authentications_changed end end @@ -891,14 +891,14 @@ def stop_event_monitor_queue end def stop_event_monitor_queue_on_change - if event_monitor_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") + if event_monitor_class && !new_record? _log.info("EMS: [#{name}], Hostname or IP address has changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end end def stop_event_monitor_queue_on_credential_change - if event_monitor_class && !new_record? && default_authentication&.changed? + if event_monitor_class && !new_record? _log.info("EMS: [#{name}], Credentials have changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end diff --git a/app/models/mixins/authentication_mixin.rb b/app/models/mixins/authentication_mixin.rb index df7c1bddfb7..0ad97ae64da 100644 --- a/app/models/mixins/authentication_mixin.rb +++ b/app/models/mixins/authentication_mixin.rb @@ -186,7 +186,7 @@ def update_authentication(data, options = {}) # Invoke before callback before_update_authentication if self.respond_to?(:before_update_authentication) && options[:save] - data.each_pair do |type, value| + authentication_changes = data.each_pair.map do |type, value| cred = authentication_type(type) current = {:new => nil, :old => nil} @@ -243,11 +243,17 @@ def update_authentication(data, options = {}) cred.auth_key = value[:auth_key] cred.service_account = value[:service_account].presence + changes = {type => cred.changes} if cred.changed? + cred.save if options[:save] && id - end + + changes + end.compact + + return if authentication_changes.blank? || !options[:save] # Invoke callback - after_update_authentication if self.respond_to?(:after_update_authentication) && options[:save] + after_update_authentication if respond_to?(:after_update_authentication) end def authentication_type(type) From 201680837be0d4720cd6ea875214c043e4fcd38b Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Wed, 7 Dec 2022 11:48:15 -0500 Subject: [PATCH 5/6] Pass endpoint/auth changes to callbacks --- app/models/ext_management_system.rb | 59 ++++++++++++++----- .../manageiq/providers/cloud_manager.rb | 4 +- app/models/mixins/authentication_mixin.rb | 6 +- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/app/models/ext_management_system.rb b/app/models/ext_management_system.rb index 4e125163948..bd0b1a089af 100644 --- a/app/models/ext_management_system.rb +++ b/app/models/ext_management_system.rb @@ -179,6 +179,9 @@ def edit_with_params(params, endpoints, authentications) endpoints_changed = false authentications_changed = false + endpoint_changes = {} + authentication_changes = {} + transaction do # Remove endpoints/attributes that are not arriving in the arguments above endpoints_to_delete = ems.endpoints.where.not(:role => nil).where.not(:role => endpoints.map { |ep| ep['role'] }) @@ -191,15 +194,23 @@ def edit_with_params(params, endpoints, authentications) ems.endpoints = endpoints.map(&method(:assign_nested_endpoint)) ems.authentications = authentications.map(&method(:assign_nested_authentication)) - endpoints_changed ||= ems.endpoints.any?(&:changed?) - authentications_changed ||= ems.authentications.any?(&:changed?) + endpoint_changes = ems.endpoints.select(&:changed?).to_h do |ep| + [ep.role.to_sym, ep.changes] + end + + authentication_changes = ems.authentications.select(&:changed?).to_h do |auth| + [auth.authtype.to_sym, auth.changes] + end + + endpoints_changed ||= endpoint_changes.present? + authentications_changed ||= authentication_changes.present? ems.provider.save! if ems.provider.present? && ems.provider.changed? ems.save! end - after_update_endpoints if endpoints_changed - after_update_authentication if authentications_changed + after_update_endpoints(endpoint_changes) if endpoints_changed + after_update_authentication(authentication_changes) if authentications_changed end end @@ -846,12 +857,12 @@ def perf_capture_enabled? # Some workers hold open a connection to the provider and thus do not # automatically pick up authentication changes. These workers have to be # restarted manually for the new credentials to be used. - def after_update_authentication - stop_event_monitor_queue_on_credential_change + def after_update_authentication(changes) + stop_event_monitor_queue_on_credential_change(changes) end - def after_update_endpoints - stop_event_monitor_queue_on_change + def after_update_endpoints(changes) + stop_event_monitor_queue_on_change(changes) end ################################### @@ -863,6 +874,14 @@ def self.event_monitor_class end delegate :event_monitor_class, :to => :class + def endpoint_role_for_events + :default + end + + def authtype_for_events + default_authentication_type + end + def event_monitor return if event_monitor_class.nil? event_monitor_class.find_by_ems(self).first @@ -890,15 +909,15 @@ def stop_event_monitor_queue ) end - def stop_event_monitor_queue_on_change - if event_monitor_class && !new_record? + def stop_event_monitor_queue_on_change(changes) + if event_monitor_class && !new_record? && changes[endpoint_role_for_events]&.keys&.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Hostname or IP address has changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end end - def stop_event_monitor_queue_on_credential_change - if event_monitor_class && !new_record? + def stop_event_monitor_queue_on_credential_change(changes) + if event_monitor_class && !new_record? && changes[authtype_for_events].present? _log.info("EMS: [#{name}], Credentials have changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue end @@ -924,6 +943,14 @@ def self.refresh_worker_class end delegate :refresh_worker_class, :to => :class + def endpoint_role_for_refresh + :default + end + + def authtype_for_refresh + default_authentication_type + end + def refresh_worker return if refresh_worker_class.nil? @@ -954,15 +981,15 @@ def stop_refresh_worker_queue ) end - def stop_refresh_worker_queue_on_change - if refresh_worker_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") + def stop_refresh_worker_queue_on_change(changes) + if refresh_worker_class && !new_record? && changes["default"]&.keys&.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Hostname or IP address has changed, stopping Refresh Worker. It will be restarted by the WorkerMonitor.") stop_refresh_worker_queue end end - def stop_refresh_worker_queue_on_credential_change - if refresh_worker_class && !new_record? && default_authentication&.changed? + def stop_refresh_worker_queue_on_credential_change(changes) + if refresh_worker_class && !new_record? && changes[authtype_for_refresh].present? _log.info("EMS: [#{name}], Credentials have changed, stopping Refresh Worker. It will be restarted by the WorkerMonitor.") stop_refresh_worker_queue end diff --git a/app/models/manageiq/providers/cloud_manager.rb b/app/models/manageiq/providers/cloud_manager.rb index 82b42c2747c..55435c299bc 100644 --- a/app/models/manageiq/providers/cloud_manager.rb +++ b/app/models/manageiq/providers/cloud_manager.rb @@ -76,8 +76,8 @@ def open_browser MiqSystem.open_browser(browser_url) end - def stop_event_monitor_queue_on_credential_change - if event_monitor_class && !new_record? && default_endpoint.changed.include_any?("hostname", "ipaddress") + def stop_event_monitor_queue_on_credential_change(changes) + if event_monitor_class && !new_record? && changes["default"]&.keys&.include_any?("hostname", "ipaddress") _log.info("EMS: [#{name}], Credentials have changed, stopping Event Monitor. It will be restarted by the WorkerMonitor.") stop_event_monitor_queue network_manager.stop_event_monitor_queue if respond_to?(:network_manager) && network_manager diff --git a/app/models/mixins/authentication_mixin.rb b/app/models/mixins/authentication_mixin.rb index 0ad97ae64da..5f57d83c926 100644 --- a/app/models/mixins/authentication_mixin.rb +++ b/app/models/mixins/authentication_mixin.rb @@ -243,17 +243,17 @@ def update_authentication(data, options = {}) cred.auth_key = value[:auth_key] cred.service_account = value[:service_account].presence - changes = {type => cred.changes} if cred.changed? + changes = [type.to_sym, cred.changes] if cred.changed? cred.save if options[:save] && id changes - end.compact + end.compact.to_h return if authentication_changes.blank? || !options[:save] # Invoke callback - after_update_authentication if respond_to?(:after_update_authentication) + after_update_authentication(authentication_changes) if respond_to?(:after_update_authentication) end def authentication_type(type) From 6f5a8656904c0d0eae8565eb5a819b5f591f255a Mon Sep 17 00:00:00 2001 From: Adam Grare Date: Fri, 9 Dec 2022 10:57:37 -0500 Subject: [PATCH 6/6] Fix `Performance/MethodObjectAsBlock` warning --- app/models/ext_management_system.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/ext_management_system.rb b/app/models/ext_management_system.rb index bd0b1a089af..79440409621 100644 --- a/app/models/ext_management_system.rb +++ b/app/models/ext_management_system.rb @@ -191,8 +191,8 @@ def edit_with_params(params, endpoints, authentications) authentications_changed ||= authentications_to_delete.delete_all > 0 ems.assign_attributes(params) - ems.endpoints = endpoints.map(&method(:assign_nested_endpoint)) - ems.authentications = authentications.map(&method(:assign_nested_authentication)) + ems.endpoints = endpoints.map { |ep| assign_nested_endpoint(ep) } + ems.authentications = authentications.map { |auth| assign_nested_authentication(auth) } endpoint_changes = ems.endpoints.select(&:changed?).to_h do |ep| [ep.role.to_sym, ep.changes]