PUPPET_LOG_LEVELS = %w[debug info notice warning err alert emerg crit].freeze reports_migrate_running = true
namespace :host_reports do
def detect_puppet_keywords(status, logs) result = ["Migrated"] # from statuses result << "PuppetFailed" if status["failed"]&.positive? result << "PuppetFailedToRestart" if status["failed_restarts"]&.positive? result << "PuppetCorrectiveChange" if status["corrective_change"]&.positive? result << "PuppetSkipped" if status["skipped"]&.positive? result << "PuppetRestarted" if status["restarted"]&.positive? result << "PuppetScheduled" if status["scheduled"]&.positive? result << "PuppetOutOfSync" if status["out_of_sync"]&.positive? # from logs logs.each do |level, resource, _message| result << "PuppetFailed:#{resource}" if level == "err" && resource != "Puppet" end result.uniq end # Puppet status cannot be directly mapped, let's create unique migration-only keywords. # See: https://community.theforeman.org/t/new-config-report-summary-columns/26531 def detect_ansible_keywords(status) result = ["Migrated"] # from statuses result << "AnsibleMigrate:Applied" if status["applied"]&.positive? result << "AnsibleMigrate:Restarted" if status["restarted"]&.positive? result << "AnsibleMigrate:Failed" if status["failed"]&.positive? result << "AnsibleMigrate:FailedRestarts" if status["failed_restarts"]&.positive? result << "AnsibleMigrate:Skipped" if status["skipped"]&.positive? result << "AnsibleMigrate:Pending" if status["pending"]&.positive? result.uniq end def puppet_metrics(metrics) return [0, 0, 0] if metrics.empty? change = metrics.dig("events", "success") || 0 failure = metrics.dig("events", "failure") || 0 total = metrics.dig("events", "total") || 0 nochange = total - change - failure [change, failure, nochange] end def summary(origin, metrics, status) change, failure, nochange = 0 case origin when "Puppet" change, failure, nochange = puppet_metrics(metrics) when "Ansible" # There is not enough data to construct the summary, it is not possible to # efficiently map ansible status values to the new format. See the discussion # at: https://community.theforeman.org/t/new-config-report-summary-columns/26531 failure = status["failed"] change = status["applied"] nochange = status["skipped"] end { foreman: { change: change, nochange: nochange, failure: failure, }, native: metrics[:resources] || {}, legacy_status: status || {}, } end def create_body(format, metrics, reported_at, _status, host, keywords, summary) { version: 1, format: format, migrated: true, host: host.name, reported_at: reported_at, keywords: keywords, summary: summary, # metrics cannot be migrated because Foreman stores them in its own way and # the new host format uses puppet native version metrics: { resources: { values: [] }, time: { values: [] }, changes: { values: [] }, events: { values: [] }, }, # keep the legacy metrics in the body in case we reconsider and transform it later legacy_metrics: metrics, } end def build_report(host_id, origin, body, report_keyword_ids) { host_id: host_id, proxy_id: nil, format: origin, reported_at: body[:reported_at], body: body.to_json, change: body.dig(:summary, :foreman, :change), nochange: body.dig(:summary, :foreman, :nochange), failure: body.dig(:summary, :foreman, :failure), report_keyword_ids: report_keyword_ids, } end def create_puppet_logs(id, log_object) logs = [["debug", "migration", "Report migrated from legacy report ID=#{id} at #{Time.now.utc}"]] log_object.includes(:message, :source).find_each do |log| logs << [PUPPET_LOG_LEVELS[log.level_id] || 'unknown', log.source.value, log.message.value] end logs end def create_ansible_result(msg, level, result = {}, task = {}) { failed: false, level: level, friendly_message: msg, result: result, task: task, } end def create_ansible_results(id, log_object) results = [] results << create_ansible_result("Report migrated from legacy report ID=#{id} at #{Time.now.utc}", "info") log_object.includes(:message, :source).find_each do |log| lvl = PUPPET_LOG_LEVELS[log.level_id] || 'unknown' msg = begin JSON.parse(log.message.value) rescue StandardError log.message.value end results << create_ansible_result(log.source.value, lvl, msg) end results end desc <<-END_DESC Migrates Foreman Configuration Reports to the new Host Reports format. Does not delete legacy reports, can be iterrupted at any time. Accepts from_date option (older reports will be ignored) and from_id, primary key (ID) to start migration from which can be used to resume previously stopped migration. Example: foreman-rake host_reports:migrate from_date=2021-01-01 from_id=1234567 END_DESC task :migrate => :environment do Rails.logger.level = Logger::ERROR Foreman::Logging.logger('permissions').level = Logger::ERROR Foreman::Logging.logger('audit').level = Logger::ERROR Signal.trap("INT") do reports_migrate_running = false end Signal.trap("TERM") do reports_migrate_running = false end from_id = (ENV['from_id'] || '0').to_i from_date = ENV['from_date'] || '1980-01-15' report_count = ConfigReport.unscoped.where("id >= ? and reported_at >= ?", from_id, from_date).count print_each = 1 + (report_count / 100).to_i puts "Starting, #{report_count} report(s) left" counter = 0 ConfigReport.unscoped.all.where("id >= ? and reported_at >= ?", from_id, from_date).find_each do |r| raise("Interrupted") unless reports_migrate_running counter += 1 puts("Processing report #{counter} out of #{report_count} reports") if (counter % print_each).zero? case r.origin when "Puppet" logs = create_puppet_logs(r.id, r.logs) keywords = detect_puppet_keywords(r.status, logs) when "Ansible" results = create_ansible_results(r.id, r.logs) keywords = detect_ansible_keywords(r.status) end if keywords.present? keywords_to_insert = keywords.each_with_object([]) do |n, ks| ks << { name: n } end ReportKeyword.upsert_all(keywords_to_insert, unique_by: :name) report_keyword_ids = ReportKeyword.where(name: keywords).distinct.pluck(:id) end summary = summary(r.origin, r.metrics, r.status) body = create_body(r.origin&.downcase, r.metrics, r.reported_at, r.status, r.host, report_keyword_ids, summary) case r.origin when "Puppet" body[:logs] = logs when "Ansible" body[:results] = results end origin = r.origin.downcase User.without_auditing do User.as_anonymous_admin do HostReport.create!(build_report(r.host_id, origin, body, report_keyword_ids)) end end rescue StandardError => e puts "Error when processing report ID=#{r.id}" puts r.inspect puts "To resume the process:\n\n***\n\nforeman-rake host_reports:migrate from_id=#{r.id} from_date=#{from_date}\n\n***\n\n" raise e end puts "\n\nALL DONE!\n\nCheck the migrated reports in Monitor - Host Reports first" puts "and when ready, expire old configuration reports with:\n\n" puts " rake reports:expire report_type=config_report days=0\n\n" puts "Report expiration is slow, if you don't use OpenSCAP plugin, then" puts "truncate the following tables in the foreman database" puts "for a quick delete (this will remove also OpenSCAP reports):\n\n" puts " truncate logs, messages, resources, reports;\n\n" puts "Reclaim postgres database space via VACUUM function in any case." puts "If migration was not successful, truncate tables host_reports and" puts "report_keywords and start over.\n" puts "Optionally, refresh host statuses with:\n\n" puts " foreman-rake host_reports:refresh\n\n" end desc <<-END_DESC Host status information can be incorrect until new report is received. This task refreshes all host statuses and global statuses. END_DESC task :refresh => :environment do Rails.logger.level = Logger::ERROR Foreman::Logging.logger('permissions').level = Logger::ERROR Foreman::Logging.logger('audit').level = Logger::ERROR User.without_auditing do User.as_anonymous_admin do Host.unscoped.all.find_each do |h| h.refresh_statuses h.refresh_global_status end end end end
end