Skip to content

Commit 8b4d283

Browse files
authored
Fixes #39185 - Track errata applications in database (#11690)
1 parent 2e31816 commit 8b4d283

12 files changed

Lines changed: 988 additions & 116 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
module Actions
2+
module Middleware
3+
# Records errata applications to database when errata installation tasks complete
4+
class RecordErrataApplication < Dynflow::Middleware
5+
def finalize
6+
pass
7+
ensure
8+
record_if_errata_job
9+
end
10+
11+
private
12+
13+
def record_if_errata_job
14+
task = find_task
15+
return unless task
16+
return unless errata_install_job?(task)
17+
18+
::Katello::ErrataApplication.record_from_task(task, action)
19+
rescue StandardError => e
20+
Rails.logger.error("Failed to record errata application: task=#{task&.id}, error=#{e.message}")
21+
Rails.logger.error(e.backtrace.join("\n"))
22+
end
23+
24+
def find_task
25+
return nil unless action.execution_plan_id
26+
27+
features = action.input['job_features']
28+
return nil unless features && (features & ['katello_errata_install', 'katello_errata_install_by_search']).any?
29+
30+
::ForemanTasks::Task.where(
31+
external_id: action.execution_plan_id,
32+
label: 'Actions::RemoteExecution::RunHostJob'
33+
).first
34+
end
35+
36+
def errata_install_job?(task)
37+
return false unless task.template_invocation
38+
return false unless task.template_invocation.template
39+
40+
task.template_invocation.template.remote_execution_features
41+
.where(label: ['katello_errata_install', 'katello_errata_install_by_search'])
42+
.exists?
43+
end
44+
end
45+
end
46+
end

app/lib/katello/concerns/base_template_scope_extensions.rb

Lines changed: 131 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module Katello
22
module Concerns
3+
# rubocop:disable Metrics/ModuleLength
34
module BaseTemplateScopeExtensions
45
extend ActiveSupport::Concern
56
extend ApipieDSL::Module
@@ -210,96 +211,26 @@ def last_checkin(host)
210211
end
211212

212213
apipie :method, 'Load errata applications' do
213-
desc 'This macro returns a collection of task records relating to errata being applied.
214+
desc 'This macro returns a collection of errata application records from the database.
214215
The collection is loaded in bulk, 1000 records at a time.'
215216
keyword :filter_errata_type, String, desc: "Errata type. One of: #{Katello::Erratum::TYPES.join(', ')}", default: nil
216217
keyword :include_last_reboot, String, desc: "Set to 'yes' to include the last reboot time of each host", default: 'yes'
217218
keyword :since, String, desc: 'Return errata applications after this date'
218219
keyword :up_to, String, desc: 'Return errata applications before this date'
219-
keyword :status, String, desc: 'Task status. One of: "pending", "success", "error", "warning"'
220+
keyword :status, String, desc: 'Application status. One of: "all", "success", "error", "warning", "cancelled"', default: 'all'
220221
keyword :host_filter, String, desc: 'A filter term to limit the resulting collection, using standard filter syntax', default: nil
221-
returns array_of: 'Erratum', desc: 'The collection that can be iterated over using each_record'
222+
returns array_of: Hash, desc: 'The collection that can be iterated over using each_record'
222223
end
223-
# rubocop:disable Metrics/MethodLength
224-
# rubocop:disable Metrics/AbcSize
225-
# rubocop:disable Metrics/CyclomaticComplexity
226-
# rubocop:disable Metrics/PerceivedComplexity
227-
def load_errata_applications(filter_errata_type: nil, include_last_reboot: 'yes', since: nil, up_to: nil, status: nil, host_filter: nil)
228-
result = []
229-
230-
filter_errata_type = filter_errata_type.presence || 'all'
231-
search_up_to = up_to.present? ? "ended_at < \"#{up_to}\"" : nil
232-
search_since = since.present? ? "ended_at > \"#{since}\"" : nil
233-
search_result = status.present? && status != 'all' ? "result = #{status}" : nil
234-
labels = 'label ^ (Actions::Katello::Host::Erratum::Install, Actions::Katello::Host::Erratum::ApplicableErrataInstall)'
235-
236-
new_labels = 'label = Actions::RemoteExecution::RunHostJob AND remote_execution_feature.label ^ (katello_errata_install, katello_errata_install_by_search)'
237-
labels = [labels, new_labels].map { |label| "(#{label})" }.join(' OR ')
238-
239-
search = [search_up_to, search_since, search_result, "state = stopped", labels].compact.join(' and ')
240-
241-
tasks = load_resource(klass: ForemanTasks::Task,
242-
permission: 'view_foreman_tasks',
243-
joins: [:template_invocation],
244-
preload: [:template_invocation],
245-
search: search)
246-
only_host_ids = ::Host.search_for(host_filter).pluck(:id) if host_filter
247-
248-
# batch of 1_000 records
249-
tasks.each do |batch|
250-
@_tasks_input = {}
251-
@_tasks_errata_cache = {}
252-
seen_errata_ids = []
253-
seen_host_ids = []
254-
255-
batch.each do |task|
256-
next if skip_task?(task)
257-
seen_errata_ids = (seen_errata_ids + parse_errata(task)).uniq
258-
seen_host_ids << get_task_input(task)['host']['id'].to_i if include_last_reboot == 'yes'
259-
end
260-
seen_host_ids &= only_host_ids if only_host_ids
261-
262-
# preload errata in one query for this batch
263-
preloaded_errata = Katello::Erratum.where(:errata_id => seen_errata_ids).pluck(:errata_id, :errata_type, :issued, :severity)
264-
preloaded_hosts = ::Host.where(:id => seen_host_ids).includes(:reported_data)
265-
266-
batch.each do |task|
267-
next if skip_task?(task)
268-
next unless only_host_ids.nil? || only_host_ids.include?(get_task_input(task)['host']['id'].to_i)
269-
parse_errata(task).each do |erratum_id|
270-
current_erratum = preloaded_errata.find { |k, _| k == erratum_id }
271-
next if current_erratum.nil?
272-
current_erratum_errata_type = current_erratum[1]
273-
current_erratum_issued = current_erratum[2]
274-
current_erratum_severity = current_erratum[3]
275-
276-
if filter_errata_type != 'all' && !(filter_errata_type == current_erratum_errata_type)
277-
next
278-
end
279-
280-
hash = {
281-
:date => task.ended_at,
282-
:hostname => get_task_input(task)['host']['name'],
283-
:erratum_id => erratum_id,
284-
:erratum_type => current_erratum_errata_type,
285-
:issued => current_erratum_issued,
286-
:severity => current_erratum_severity,
287-
:status => task.result,
288-
}
289-
290-
if include_last_reboot == 'yes'
291-
# It is possible that we can't find the host if it has been deleted.
292-
hash[:last_reboot_time] = preloaded_hosts.find { |k, _| k.id == get_task_input(task)['host']['id'].to_i }&.uptime_seconds&.seconds&.ago
293-
end
294-
295-
result << hash
296-
end
297-
end
298-
end
299-
300-
result
224+
def load_errata_applications(filter_errata_type: nil, include_last_reboot: 'yes', since: nil, up_to: nil, status: 'all', host_filter: nil)
225+
load_errata_applications_from_db(
226+
filter_errata_type: filter_errata_type,
227+
include_last_reboot: include_last_reboot,
228+
since: since,
229+
up_to: up_to,
230+
status: status,
231+
host_filter: host_filter
232+
)
301233
end
302-
# rubocop:enable Metrics/MethodLength
303234

304235
apipie :method, 'Converts package version to be sortable' do
305236
required :version, String, desc: 'Version to convert'
@@ -321,47 +252,131 @@ def configure_host_for_new_content_source(host, ca_cert)
321252
prepare_ssl_cert(ca_cert) + configure_subman(host.content_source)
322253
end
323254

324-
private
325-
326-
def host_subscription_facet(host)
327-
host.subscription_facet
255+
apipie :method, 'Load errata applications from database' do
256+
desc 'This macro returns a collection of errata application records from the database.
257+
This is the new recommended approach that uses dedicated database tracking instead of parsing tasks.'
258+
keyword :filter_errata_type, String, desc: "Errata type. One of: #{Katello::Erratum::TYPES.join(', ')}", default: nil
259+
keyword :include_last_reboot, String, desc: "Set to 'yes' to include the last reboot time of each host", default: 'yes'
260+
keyword :since, String, desc: 'Return errata applications after this date'
261+
keyword :up_to, String, desc: 'Return errata applications before this date'
262+
keyword :status, String, desc: 'Application status. One of: "all", "success", "error", "warning", "cancelled"', default: 'all'
263+
keyword :host_filter, String, desc: 'A filter term to limit the resulting collection, using standard filter syntax', default: nil
264+
returns array_of: Hash, desc: 'Array of hashes containing errata application data'
328265
end
266+
# rubocop:disable Metrics/MethodLength
267+
# rubocop:disable Metrics/AbcSize
268+
# rubocop:disable Metrics/CyclomaticComplexity
269+
# rubocop:disable Metrics/PerceivedComplexity
270+
def load_errata_applications_from_db(filter_errata_type: nil, include_last_reboot: 'yes', since: nil, up_to: nil, status: 'all', host_filter: nil)
271+
result = []
329272

330-
def skip_task?(task)
331-
# Skip task that doesn't apply errata
332-
input = get_task_input(task)
333-
input.blank? || input['host'].blank?
334-
end
273+
authorized_hosts = ::Host::Managed.authorized('view_hosts').select(:id)
335274

336-
def get_task_input(task)
337-
@_tasks_input[task.id] ||= if task.label == 'Actions::Katello::Host::Erratum::ApplicableErrataInstall'
338-
task.execution_plan_action.all_planned_actions(Actions::Katello::Host::Erratum::Install).first.try(:input) || {}
339-
else
340-
task.input
341-
end
342-
end
275+
# Build scoped search query for date and status filters
276+
search_conditions = []
277+
search_conditions << "applied_at > \"#{since}\"" if since.present?
278+
search_conditions << "applied_at < \"#{up_to}\"" if up_to.present?
343279

344-
def parse_errata(task)
345-
task_input = get_task_input(task)
346-
agent_input = task_input['errata'] || task_input['content']
347-
# agent_input retrieves past katello-agent tasks.
348-
# There are multiple template inputs, such as errata, pre_script and post_script.
349-
# We only need the errata input here.
350-
@_tasks_errata_cache[task.id] ||= agent_input.presence || errata_ids_from_template_invocation(task, task_input)
351-
end
280+
if status.present? && status != 'all'
281+
return [] if status.to_s.casecmp('pending').zero?
282+
search_conditions << "status = #{status.to_s.downcase}"
283+
end
284+
285+
applications = if search_conditions.any?
286+
Katello::ErrataApplication.where(host_id: authorized_hosts)
287+
.search_for(search_conditions.join(' and '))
288+
else
289+
Katello::ErrataApplication.where(host_id: authorized_hosts)
290+
end
291+
292+
if host_filter.present?
293+
applications = applications.where(host_id: ::Host.search_for(host_filter).select(:id))
294+
end
352295

353-
def errata_ids_from_template_invocation(task, task_input)
354-
if task_input.key?('job_features') && task_input['job_features'].include?('katello_errata_install_by_search')
355-
# This may give wrong results if the template is not rendered yet
356-
# This also will not work for jobs run before we started storing
357-
# resolved ids in the template
358-
script = task.execution_plan.actions[1].try(:input).try(:[], 'script') || ''
359-
found = script.lines.find { |line| line.start_with? '# RESOLVED_ERRATA_IDS=' } || ''
360-
(found.chomp.split('=', 2).last || '').split(',')
296+
# Preload hosts
297+
if include_last_reboot == 'yes'
298+
applications = applications.includes(host: :reported_data)
361299
else
362-
TemplateInvocationInputValue.joins(:template_input).where("template_invocation_id = ? AND template_inputs.name = ?", task.template_invocation.id, 'errata')
363-
.first&.value&.split(',') || []
300+
applications = applications.includes(:host)
301+
end
302+
303+
# Fetch host_ids and errata_ids in a single query
304+
application_data = applications.pluck(:host_id, :errata_ids)
305+
host_ids = application_data.map(&:first).uniq
306+
all_errata_ids = application_data.map(&:last).flatten.uniq
307+
308+
# Preload all errata to avoid N+1 queries (join on errata_id string)
309+
errata_by_errata_id = Katello::Erratum.where(errata_id: all_errata_ids).index_by(&:errata_id)
310+
311+
# Fetch applicability only for errata that were applied
312+
applicable_data = Katello::Host::ContentFacet
313+
.where(host_id: host_ids)
314+
.joins(:applicable_errata)
315+
.where('katello_errata.errata_id' => all_errata_ids)
316+
.pluck(:host_id, 'katello_errata.errata_id')
317+
318+
applicable_errata_map =
319+
applicable_data
320+
.group_by(&:first)
321+
.transform_values { |pairs| pairs.map(&:last) }
322+
323+
# Process each application record
324+
applications.find_each(batch_size: 1000) do |app|
325+
# Get applicable erratum IDs for this host (from pre-built map)
326+
applicable_erratum_ids = applicable_errata_map[app.host_id] || []
327+
328+
# Create one output row per erratum
329+
app.errata_ids.each do |erratum_string_id|
330+
erratum = errata_by_errata_id[erratum_string_id]
331+
332+
# If erratum doesn't exist in DB (deleted/disabled repo), still create output with limited data
333+
hash = if erratum
334+
{
335+
date: app.applied_at,
336+
hostname: app.host.name,
337+
erratum_id: erratum.errata_id,
338+
erratum_type: erratum.errata_type,
339+
issued: erratum.issued,
340+
severity: erratum.severity,
341+
status: app.status,
342+
still_applicable: applicable_erratum_ids.include?(erratum_string_id),
343+
}
344+
else
345+
# Erratum no longer in system, but we still have the application record
346+
{
347+
date: app.applied_at,
348+
hostname: app.host.name,
349+
erratum_id: erratum_string_id,
350+
erratum_type: nil,
351+
issued: nil,
352+
severity: nil,
353+
status: app.status,
354+
still_applicable: false,
355+
}
356+
end
357+
358+
# Filter by errata type if specified (skip if we don't have the erratum object or type doesn't match)
359+
next if filter_errata_type.present? && filter_errata_type != 'all' && (erratum.nil? || erratum.errata_type != filter_errata_type)
360+
361+
if include_last_reboot == 'yes'
362+
hash[:last_reboot_time] = app.host&.uptime_seconds&.seconds&.ago
363+
end
364+
365+
result << hash
366+
end
364367
end
368+
369+
result
370+
end
371+
# rubocop:enable Metrics/MethodLength
372+
# rubocop:enable Metrics/AbcSize
373+
# rubocop:enable Metrics/CyclomaticComplexity
374+
# rubocop:enable Metrics/PerceivedComplexity
375+
376+
private
377+
378+
def host_subscription_facet(host)
379+
host.subscription_facet
365380
end
366381
end
367382
end

app/models/katello/concerns/host_managed_extensions.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def remote_execution_proxies(provider, *_rest)
8484
has_many :host_collections, :class_name => "::Katello::HostCollection", :through => :host_collection_hosts
8585

8686
has_many :hypervisor_pools, :class_name => '::Katello::Pool', :foreign_key => :hypervisor_id, :dependent => :nullify
87+
has_many :errata_applications, :class_name => '::Katello::ErrataApplication', :inverse_of => :host, :dependent => :destroy
8788

8889
validates :name, format: { with: Net::Validations::HOST_REGEXP, message: _("%{value} can contain only lowercase letters, numbers, dashes and dots.") }
8990

@@ -96,6 +97,8 @@ def remote_execution_proxies(provider, *_rest)
9697
after_validation :queue_refresh_content_host_status
9798
register_rebuild(:queue_refresh_content_host_status, N_("Refresh_Content_Host_Status"))
9899

100+
register_rebuild(:clear_errata_applications_on_rebuild, N_("Clear_Errata_Applications"))
101+
99102
scope :image_mode, -> do
100103
joins(:content_facet).where.not("#{::Katello::Host::ContentFacet.table_name}.bootc_booted_image" => nil)
101104
end
@@ -177,6 +180,10 @@ def should_reset_content_host_status?
177180
!new_record? && build && self.changes.key?('build')
178181
end
179182

183+
def clear_errata_applications_on_rebuild
184+
errata_applications.delete_all
185+
end
186+
180187
module ClassMethods
181188
def find_by_installed_debs(_key, _operator, value)
182189
name, architecture, version = Katello::Deb.split_nav(value)

app/models/katello/concerns/user_extensions.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module UserExtensions
66
included do
77
has_many :activation_keys, :dependent => :nullify, :class_name => "Katello::ActivationKey"
88
has_many :subscription_facets, :dependent => :nullify, :class_name => "Katello::Host::SubscriptionFacet"
9+
has_many :errata_applications, :dependent => :nullify, :class_name => "Katello::ErrataApplication", :inverse_of => :user
910

1011
def self.remote_user
1112
SETTINGS[:katello][:pulp][:default_login]

0 commit comments

Comments
 (0)