### Copyright 2016 Pixar ### ### Licensed under the Apache License, Version 2.0 (the "Apache License") ### with the following modification; you may not use this file except in ### compliance with the Apache License and the following modification to it: ### Section 6. Trademarks. is deleted and replaced with: ### ### 6. Trademarks. This License does not grant permission to use the trade ### names, trademarks, service marks, or product names of the Licensor ### and its affiliates, except as required to comply with Section 4(c) of ### the License and to reproduce the content of the NOTICE file. ### ### You may obtain a copy of the Apache License at ### ### http://www.apache.org/licenses/LICENSE-2.0 ### ### Unless required by applicable law or agreed to in writing, software ### distributed under the Apache License with the above modification is ### distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY ### KIND, either express or implied. See the Apache License for the specific ### language governing permissions and limitations under the Apache License. ### ### ### module D3 module Admin module Report extend self ### TODO add report of last_usage for expirable rcpts ###### Reports for 'd3admin report' ### Report on all computer receipts for a given basename def report_basename_receipts (basename, statuses) unless D3::Package.all_basenames.include? basename puts "# No basename '#{basename}' in d3" return end # get the raw data raw_data = computer_receipts_data got_ea = D3::CONFIG.report_receipts_ext_attr_name if raw_data.nil? or raw_data.empty? puts "# No computers with receipts for '#{basename}'" return end # json leaves status as a string statuses = statuses.map{|s| s.to_s} # this separates out the frozen filtering from the status filtering # statuses are OR'd, all of them are ANDd with frozen # and lets us build meaningful header lines filter_frozen = statuses.include? "frozen" if filter_frozen statuses.delete("frozen") status_display = " frozen #{statuses.join(" or ")}" else status_display = " #{statuses.join(" or ")}" end # set the title... reporting on which recipts? title = "All computers with#{status_display} '#{basename}' receipts" # set the header if got_ea header = %w{Computer User Edition Status As_of Frozen Installed By} else header =%w{Computer User Edition Status As_of } end # case lines= [] raw_data.each do |computer| next unless computer # skip computers without this basename next unless computer[:rcpts] and computer[:rcpts].keys.include?(basename) rcpt = computer[:rcpts][basename] # if we were asked for frozen, skip rcpts not frozen if filter_frozen next unless rcpt[:frozen] end # if we were asked for certain statuses, # skip rcpts without that status unless statuses.empty? next unless statuses.include? rcpt[:status] end # build a line for this rcpt rcpt_line = [] rcpt_line << computer[:computer] rcpt_line << computer[:user] rcpt_line << "#{basename}-#{rcpt[:version]}-#{rcpt[:revision]}" rcpt_line << rcpt[:status] rcpt_line << (computer[:as_of] ? computer[:as_of].strftime("%Y-%m-%d") : nil) if got_ea rcpt_line << (rcpt[:frozen] ? "frozen" : "-") rcpt_line << (rcpt[:installed_at] ? rcpt[:installed_at].strftime("%Y-%m-%d") : nil) rcpt_line << rcpt[:admin] end # if rcpt[:installed_at] lines << rcpt_line end # raw_data.each do |computer| if lines.empty? puts "# No#{status_display} receipts for '#{basename}' were found" else D3.less_text D3.generate_report(lines.sort_by{|c| c[0]}, header_row: header, title: title) end # if lines emtpy? end # report_basename_receipts (basename, statuses) ### Show a report of all current d3 rcpts on a given computer ### ### @param computer[String] the name of the computer to report on ### ### @param statuses[Array] the statuses to report on, all if empty ### ### @return [void] ### def report_single_computer_receipts (computer_name, statuses) unless JSS::Computer.all_names.include? computer_name puts "# No computer named '#{computer_name}' in Casper" return end computer = JSS::Computer.new name: computer_name ea_name = D3::CONFIG.report_receipts_ext_attr_name # data from EA? if ea_name ea_data = computer.extension_attributes.select{|ea| ea[:name] == ea_name}.first[:value] if ea_data.empty? puts "No d3 receipts on computer '#{computer_name}'" return false elsif not ea_data.start_with?('{') puts "The '#{ea_name}' extention attribute data for computer '#{computer_name}' is bad" return false end # if ea_data.empty? rcpt_data = JSON.parse ea_data , :symbolize_names => true # no EA, use casper rcpts else pkg_filenames_to_ids = D3::Package.all_filenames.invert rcpt_data = {} computer.software[:installed_by_casper].each do |jrcpt| rcpt_id = pkg_filenames_to_ids[jrcpt] # some might be zipped rcpt_id ||= pkg_filenames_to_ids[jrcpt + ".zip"] next unless rcpt_id pkg_data = D3::Package.package_data[rcpt_id] next unless pkg_data rcpt_data[pkg_data[:basename]] = {:version =>pkg_data[:version], :revision => pkg_data[:revision], :status => pkg_data[:status]} end # computer.software[:installed_by_casper].each end # if ea_name ... else # now rcpt_data is a hash of hashes {basename => { version, etc...} } # start building the report # title last_recon = computer.last_recon.strftime("%Y-%m-%d") title = "Receipts on '#{computer_name}' (user: #{computer.username}) as of #{last_recon}" # header... if ea_name header = %w{Edition Status As_of Frozen Installed By } else header = %w{Edition Status As_of } end # case # json leaves status as a string statuses = statuses.map{|s| s.to_s} # this separates out the frozen filtering from the status filtering # statuses are OR'd, all of them are ANDd with frozen # and lets us build meaningful header lines filter_frozen = statuses.include? "frozen" if filter_frozen statuses.delete("frozen") status_display = " frozen #{statuses.join(" or ")}" else status_display = " #{statuses.join(" or ")}" end lines = [] # sort by basename rcpt_data.keys.sort.each do |basename| rcpt = rcpt_data[basename] # skip unwanted stati unless statuses.empty? next unless statuses.include? rcpt[:status] end # skip thawed if needed if filter_frozen next unless rcpt[:frozen] end rcpt_line = [] rcpt_line << "#{basename}-#{rcpt[:version]}-#{rcpt[:revision]}" rcpt_line << rcpt[:status] rcpt_line << computer.last_recon.strftime("%Y-%m-%d") if ea_name rcpt_line << (rcpt[:frozen] ? "frozen" : "-") rcpt_line << Time.parse(rcpt[:installed_at]).strftime("%Y-%m-%d") rcpt_line << rcpt[:admin] end # rcpt[:installed_at] lines << rcpt_line end # rcpt_data.keys.sort do |basename| if lines.empty? statuses<<("frozen") if filter_frozen stati = statuses.empty? ? '' : " #{ statuses.join(' or ')}" puts "# No#{stati} receipts on '#{computer_name}'" else D3.less_text D3.generate_report lines, header_row: header, title: title end end # report_single_computer_receipts ### Report a basename in all computers' puppy queues ### ### @param basename[String] ### ### @param statuses[Array] ### ### @return [void] ### def report_puppy_queues (basename, statuses) report_data = Report.computer_puppyq_data unless report_data puts "Reports of pending puppies require a special Extension Attribute. Please see the d3 documentation" return false end # json loads symbols as strings statuses = statuses.map{|s| s.to_s} status_display = " #{statuses.join(", ")}" title = "All computers with '#{basename}' in the puppy queue" header = %w{Computer User Edition Status Queued By As-of} lines = [] report_data.each do |computer_to_report| this_pup = computer_to_report[:pups][basename] # skip if we don't have this basename next unless this_pup # skip unwanted statuses unless statuses.empty? next unless statuses.include? this_pup[:status] end edition = "#{basename}-#{this_pup[:version]}-#{this_pup[:revision]}" qd_at = Time.parse(this_pup[:queued_at]).strftime "%Y-%m-%d" as_of = Time.parse(computer_to_report[:as_of]).strftime "%Y-%m-%d" lines << [computer_to_report[:computer], computer_to_report[:user], edition, this_pup[:status], qd_at, this_pup[:admin], as_of] end # report_data.each do |computer_to_report| if lines.empty? puts "# No computers with '#{basename}' queued." else D3.less_text D3.generate_report lines, header_row: header, title: title end # if lines emtpy? end # ef puppy_installs (basenames) ### Report a single computer's puppy queue ### ### @param computer_name[String] ### ### @param statuses[Array] ### ### @return [void] ### def report_single_puppy_queue (computer_name, statuses) ea_name = D3::CONFIG.report_puppyq_ext_attr_name unless ea_name puts "Reports of pending puppies require a special Extension Attribute. Please see the d3 documentation" return false end unless JSS::Computer.all_names.include? computer_name puts "No computer named '#{computer_name}' in Casper" return false end computer = JSS::Computer.new name: computer_name ea_data = computer.extension_attributes.select{|ea| ea[:name] == ea_name}.first[:value] if ea_data.empty? puts "No puppies in the queue on computer '#{computer_name}'" return false elsif not ea_data.start_with?('{') puts "The '#{ea_name}' extention attribute data for computer '#{computer_name}' is bad" return false end title = "All items in the puppy queue on '#{computer_name}' (user: #{computer.username})" header = %w{Edition Status Queued By As-of} lines = [] ea_data = JSON.parse ea_data, :symbolize_names => true # in json data, symbols became strings statuses = statues.map{|s| s.to_s} status_display = " #{statuses.join(", ")}" ea_data.each do |basename,pup| next unless statuses.include? pup[:status] edition = "#{basename}-#{pup[:version]}-#{pup[:revision]}" qa = Time.parse(pup[:queued_at]).strftime "%Y-%m-%d" as_of = computer.last_recon.strftime s"%Y-%m-%d" lines << [edition, pup[:status], qa, pup[:admin], as_of] end D3.less_text D3.generate_report lines, header_row: header, title: title end #report_single_puppy_queue (computer_name, statuses) ###### Lists for d3admin walkthru ### Show a list of all package editions, pkg names and filenames ### known to d3 along with their status ### ### @return [void] ### def show_existing_package_ids # get them in alphabetical order #sorted_pkgs = D3::Package.package_data.values.sort{|a,b| a[:name] <=> b[:name]} sorted_pkgs = D3::Package.package_data.values.sort_by{|p| p[:name].downcase} # here's the columns we care about header = %w{ edition pkg_name filename JSS_id status} # map each one to an array of desired data lines = sorted_pkgs.map{|p| [ p[:edition], p[:name], D3::Package.all_filenames[p[:id]], p[:id], p[:status] ] } D3.less_text D3.generate_report lines, header_row: header, title: "Packages in d3" end ### Show a list of all basenames known to d3 ### along with the status of the most recent package with that basename ### ### This is generally used with walkthrus. ### def show_all_basenames_and_editions sorted_data = D3::Package.package_data.values.sort_by{|p| p[:edition] } # here's the columns we care about header = %w{basename edition status} # map each one to an array of desired data lines = sorted_data.map{ |p| [ p[:basename], p[:edition], p[:status]] } D3.less_text D3.generate_report lines, header_row: header, title: "Basenames and Editions in d3." end ### Show a list of JSS package names that are NOT in d3. ### ### @return [void] def show_pkgs_available_for_import lines = (JSS::Package.all_names - D3::Package.all_names).sort.map{|p| [p]} header = ['Package name'] D3.less_text D3.generate_report lines, header_row: header, title: "JSS Packages available for importing to d3" end ### Show a list of computers in the JSS, to select one for reporting ### ### @return [void] ### def show_available_computers_for_reports lines = JSS::Computer.all_names.sort.map{|c| [c]} header = ['Computer name'] D3.less_text D3.generate_report lines, header_row: header, title: "Computers in the JSS" end ###### Lists for 'd3admin search' ### Display a list of pkgs on the server ### ### @param title[String] the title of the displayed list ### ### @param ids[Array] an array of pkgs id's about which to ### display info. ### ### @param no_match_text[String] the text to display when there are no ids ### @return [void] ### def display_package_list (title, ids, no_match_text = "No matchings packages", show_scope = false ) date_fmt = "%Y-%m-%d" header = show_scope ? %w{ Edition Status Auto_Groups Excluded_Groups } : %w{ Edition Status Added By Released By } lines = [] ids.each do |pkgid| p = D3::Package.package_data[pkgid] next unless p if show_scope auto_gs = p[:auto_groups].empty? ? "-none-" : p[:auto_groups].join(",") excl_gs = p[:excluded_groups].empty? ? "-none-" : p[:excluded_groups].join(",") lines << [p[:edition], p[:status], auto_gs, excl_gs] else date_added = p[:added_date] ? p[:added_date].strftime(date_fmt) : "-" date_released = p[:release_date] ? p[:release_date].strftime(date_fmt) : "-" rel_by = p[:released_by] ? p[:released_by] : "-" lines << [p[:edition], p[:status], date_added, p[:added_by], date_released, rel_by] end # if show_scope end if lines.empty? puts no_match_text puts # empty line between return end lines.sort_by! {|l| l[0]} D3.less_text D3.generate_report(lines, header_row: header, title: title) puts # empty line between end # display_package_list ### Show a list of pkgs from the d3admin 'search' action ### ### @param basename[String] the basename of pkgs to show ### ### @param statuses[Array] only show these statuses ### ### @return [void] ### def list_packages (basename = nil , statuses = []) pkg_data = D3::Package.package_data if basename title = "All '#{basename}' packages in d3" ids = pkg_data.values.select{|p| p[:basename] == basename }.map{|p| p[:id]} else title = "All packages in d3" ids = pkg_data.keys end # if basename unless statuses.empty? title += " with status #{statuses.join(' or ')}" statuses = statuses.map{|s| s.to_sym} status_display = " #{statuses.join(", ")}" ids = ids.select{|pid| statuses.include? pkg_data[pid][:status] } end # if what_to_show == :all display_package_list title, ids, "No matching packages" end # def list_packages ### Show a list of all packages with their scoped groups ### ### @param statuses[Array] only show these statuses ### ### @return [void] ### def list_all_pkgs_with_scope (statuses) title = "Group Scoping for all packages" title += " with status #{statuses.join(' or ')}" unless statuses.empty? if statuses.empty? ids = D3::Package.all_ids else ids = D3::Package.package_data.values.select{|pd| statuses.include? pd[:status].to_s }.map{|pd| pd[:id]} end D3::Admin::Report.display_package_list title, ids, 'No Matching Groups', :show_scope end ### list packages that auto-install onto machines ### in one or more given groups ### ### @param groups[String,Array] the group or groups to show. ### ### @return [void] ### def list_scoped_installs(group, statuses, scope = :auto) scope_text = scope == :auto ? "auto-install" : "are excluded for" title = "Packages that #{scope_text} for group '#{group}'" if JSS::ComputerGroup.all_names.include? group or D3::STANDARD_AUTO_GROUP == group ids = scope == :auto ? D3::Package.auto_install_ids_for_group(group) : D3::Package.exclude_ids_for_group(group) unless statuses.empty? title += " with status #{statuses.join(' or ')}" statuses = statuses.map{|s| s.to_sym} status_display = " #{statuses.join(", ")}" ids = ids.select{|pid| statuses.include? D3::Package.package_data[pid][:status] } end # if what_to_show == :all no_match_text = "No packages #{scope_text} for group '#{group}'" # no such group else ids = [] no_match_text = "No computer group named '#{group}'" end # if JSS::ComputerGroup.all_names.include? group display_package_list title, ids, no_match_text end # list_scoped_installs ###### Data gathering ### Reconnect to both the API and DB with a much larger timeout, and ### using an alternate DB server if one is defined. ### ### @return [Hash] the hostnames of the connected JSS & MySQL servers ### def connect_for_reports api = D3::Admin::Auth.rw_credentials :jss db = D3::Admin::Auth.rw_credentials :db D3.connect_for_reports api[:user], api[:password], db[:user], db[:password] end # connect for report ### Get the raw data for a client-install report, from the EA if available ### or from the JAMF receipts if not. ### ### Returns an array of the report data (see ### client_install_ea_report_data and client_install_jamf_rcpt_report_data) ### ### @return [Array] The data for doing client install reports ### def computer_receipts_data puts "Querying Casper for receipt data..." the_data = computer_receipts_ea_data return the_data if the_data return computer_receipts_jamf_data end ### Get the latest data from the D3::CONFIG.report_receipts_ext_attr_name ### if that EA exists, nil otherwise ### ### The result is an Array of Hashes, one for each computer in Casper. ### Each hash contains these keys: ### :computer - the name of the computer ### :user - the name of the comptuer's user ### :as_of - the Time when the data was gathered ### :rcpts - a Hash of receipt data for the computer, keyed by basename. ### ### Each receipt in the :rcpts hash contains these keys ### :version ### :revision ### :status ### :installed_at ### :admin ### :frozen ### :manual ### :custom_expiration ### :last_usage ### ### @return [Array, nil] The data from the extension attribute, nil ### if we aren't configured for the EA. ### def computer_receipts_ea_data return nil unless D3::CONFIG.report_receipts_ext_attr_name connect_for_reports ea = JSS::ComputerExtensionAttribute.new :name => D3::CONFIG.report_receipts_ext_attr_name # while we could get the data via the API by calling: result = ea.latest_values # but thats very slow, because it creates a temporary AdvancedSearch, # and retrieves it's results, and API access is always pretty slow # Going directly to SQL is WAY faster and since this is D3, we can. q = <<-ENDQ SELECT c.computer_id, c.computer_name, c.username, c.last_report_date_epoch AS as_of, eav.value_on_client AS value FROM computers_denormalized c JOIN extension_attribute_values eav ON c.last_report_id = eav.report_id WHERE eav.extension_attribute_id = #{ea.id} ENDQ result = JSS::DB_CNX.db.query q report_data = [] result.each_hash do |computer_ea_result| computer_data = {} computer_data[:computer] = computer_ea_result["computer_name"].to_s computer_data[:user] = computer_ea_result["username"] computer_data[:as_of] = (JSS.epoch_to_time computer_ea_result["as_of"]) rcpts ={} if computer_ea_result['value'].start_with? '{' rcpt_data = JSON.parse computer_ea_result['value'], :symbolize_names => true rcpt_data.each do |basename, raw| this_r = {} this_r[:version] = raw[:version] this_r[:revision] = raw[:revision].to_i this_r[:status] = raw[:status] this_r[:installed_at] = raw[:installed_at] ? Time.parse(raw[:installed_at]) : nil this_r[:admin] = raw[:admin] this_r[:frozen] = raw[:frozen] this_r[:manual] = raw[:manual] this_r[:custom_expiration] = raw[:custom_expiration] this_r[:last_usage] = raw[:last_usage] ? Time.parse(raw[:last_usage]) : nil # the basename got symbolized, so re-string it rcpts[basename.to_s] = this_r end # rcpt_data.each do |basename, raw| end # if ea_result['value'].start_with? '{' computer_data[:rcpts] = rcpts report_data << computer_data end # result.each_hash do |ea_result| return report_data end # def ea_report_data ### get the latest receipt data from Caspers receipts table ### This is used if the D3::CONFIG.report_receipts_ext_attr_name is not set ### and the data it returns is less useful. ### ### The result is and Array of Hashes, one for each computer in Casper. ### Each hash contains these keys: ### :computer - the name of the computer ### :user - the name of the comptuer's user ### :as_of - the Time when the data was gathered ### :rcpts - a Hash of receipt data for the computer, keyed by basename. ### ### Each receipt in the :rcpts hash contains these keys ### :version ### :revision ### :status ### ### @return [Array] The data from the jamf receipts ### def computer_receipts_jamf_data q = <<-ENDQ SELECT c.computer_name, c.username, GROUP_CONCAT(r.package_name) AS jamf_receipts, c.last_report_date_epoch AS as_of FROM computers_denormalized c JOIN package_receipts r ON c.computer_id = r.computer_id WHERE r.type = 'jamf' GROUP BY c.computer_id ENDQ res = JSS::DB_CNX.db.query q report_data = [] pkg_filenames_to_ids = D3::Package.all_filenames.invert res.each_hash do |record| computer_data = {:computer => record['computer_name']} computer_data[:as_of] = JSS.epoch_to_time record['as_of'] computer_data[:user] = record['username'] computer_data[:rcpts] = {} record['jamf_receipts'].split(',').each do |jrcpt| rcpt_id = pkg_filenames_to_ids[jrcpt] # some might be zipped rcpt_id ||= pkg_filenames_to_ids[jrcpt + ".zip"] next unless rcpt_id pkg_data = D3::Package.package_data[rcpt_id] next unless pkg_data computer_data[:rcpts][pkg_data[:basename]] = {:version =>pkg_data[:version], :revision => pkg_data[:revision], :status => pkg_data[:status]} end #record['jamf_receipts'].split(',').each do report_data << computer_data end # res.each_hash do |record| res.free return report_data end # def jamf_rcpt_report_data ### get the latest puppy queue data from the puppy q EA, if available. def computer_puppyq_data return nil unless D3::CONFIG.report_puppyq_ext_attr_name ea = JSS::ComputerExtensionAttribute.new :name => D3::CONFIG.report_puppyq_ext_attr_name q = <<-ENDQ SELECT c.computer_id, c.computer_name, c.username, c.last_report_date_epoch AS as_of, eav.value_on_client AS value FROM computers_denormalized c JOIN extension_attribute_values eav ON c.last_report_id = eav.report_id WHERE eav.extension_attribute_id = #{ea.id} ENDQ result = JSS::DB_CNX.db.query q report_data = [] result.each_hash do |ea_result| pups ={} if ea_result['value'].start_with? '{"' # the ea contains the full receipt YAML. # for now we only need this subset of it. pup_data = JSON.parse ea_result['value'], :symbolize_names => true pup_data.each do |basename, raw| this_p = {} this_p[:version] = raw[:version] this_p[:revision] = raw[:revision].to_i this_p[:status] = raw[:status].to_sym this_p[:queued_at] = raw[:queued_at] ? Time.parse(raw[:queued_at]) : nil this_p[:admin] = raw[:admin] this_p[:custom_expiration] = raw[:custom_expiration] pups[basename] = this_p end # each do r end # if ea_result['value'].start_with? '{"' report_data << { :computer => ea_result["computer_name"], :user => ea_result["username"], :pups => pups, :as_of => (JSS.epoch_to_time ea_result["as_of"]) } end # result.each_hash do |ea_result| return report_data end # def client_puppyq_report_data end # module Report end # module Admin end # module D3