module Jamf::Utility
A collection of useful utility methods. Mostly for converting values between formats, parsing data, and user interaction. This module should be extended into the Jamf
Module so all methods become module methods
Constants
- MAC_OS_MAXS
Hash
of ‘major’ => ‘minor’ The maximum minor release for macOS major.minor e.g. the highest release of 11 is 11.1212 is the default for the current OS and higher (and hoping apple doesn’t release, e.g., 11.13)
- OS_TEN_MAXS
Hash
of ‘minor’ => ‘maint’ The maximum maint release for macOS 10.minor.maint e.g the highest release of 10.6 was 10.6.8, the highest release of 10.15 was 10.15.712 is the default for the current OS and higher (and hoping apple doesn’t release 10.16.13)
Public Instance Methods
Given a name, singular or plural, of a Jamf::APIObject
subclass as a String
or Symbol (e.g. :computer/‘computers’), return the class itself (e.g. Jamf::Computer
) The available names are the RSRC_LIST_KEY and RSRC_OBJECT_KEY values for each APIObject
subclass.
@seealso Jamf.cnx_object_names
@param name The name of a Jamf::APIObject
subclass, singluar
or plural
@return [Class] The class
# File lib/jamf/utility.rb 412 def api_object_class(name) 413 klass = api_object_names[name.downcase.to_sym] 414 raise Jamf::InvalidDataError, "Unknown API Object Class: #{name}" unless klass 415 416 klass 417 end
APIObject
subclasses have singular names, and are, of course capitalized, e.g. ‘Computer’ But we often want to refer to them in the plural, or lowercase, e.g. ‘computers’ This method returns a Hash
of the RSRC_LIST_KEY (a plural symbol) and the RSRC_OBJECT_KEY (a singular symbol) of each APIObject
subclass, keyed to the class itself, such that both :computer and :computers are keys for Jamf::Computer
and both :policy and :policies are keys for Jamf::Policy
, and so on.
@return [Hash] APIObject
subclass names to Classes
# File lib/jamf/utility.rb 431 def api_object_names 432 return @api_object_names if @api_object_names 433 434 @api_object_names ||= {} 435 JSS.constants.each do |const| 436 klass = JSS.const_get const 437 next unless klass.is_a? Class 438 next unless klass.ancestors.include? Jamf::APIObject 439 440 @api_object_names[klass.const_get(:RSRC_LIST_KEY).to_sym] = klass if klass.constants.include? :RSRC_LIST_KEY 441 @api_object_names[klass.const_get(:RSRC_OBJECT_KEY).to_sym] = klass if klass.constants.include? :RSRC_OBJECT_KEY 442 end 443 @api_object_names 444 end
Given an element name and an array of content, generate an Array
of REXML::Element objects with that name, and matching content. Given element name ‘foo’ and the array [‘bar’,‘morefoo’] The array of REXML elements would render thus:
<foo>bar</foo> <foo>morefoo</foo>
@param element [#to_s] an element_name like :foo
@param list [Array<#to_s>] an Array
of element content such as [“bar”, :morefoo]
@return [Array<REXML::Element>]
# File lib/jamf/utility.rb 474 def array_to_rexml_array(element, list) 475 raise Jamf::InvalidDataError, 'Arg. must be an Array.' unless list.is_a? Array 476 477 element = element.to_s 478 list.map do |v| 479 e = REXML::Element.new(element) 480 e.text = v 481 e 482 end 483 end
un/set devmode mode. Useful when coding - methods can call JSS.devmode? and then e.g. spit out something instead of performing some action.
@param [Symbol] Set devmode :on or :off
@return [Boolean] The new state of devmode
# File lib/jamf/utility.rb 643 def devmode(setting) 644 @devmode = setting == :on 645 end
is devmode currently on?
@return [Boolean]
# File lib/jamf/utility.rb 651 def devmode? 652 @devmode 653 end
Converts JSS
epoch (unix epoch + milliseconds) to a Ruby Time
object
@param epoch[String, Integer, nil]
@return [Time, nil] nil is returned if epoch is nil, 0 or an empty String
.
# File lib/jamf/utility.rb 393 def epoch_to_time(epoch) 394 return nil if NIL_DATES.include? epoch 395 396 Time.at(epoch.to_i / 1000.0) 397 end
Given a string of xml element text, escape any characters that would make XML unhappy.
* & => & * " => " * < => < * > => > * ' => '
@param string [String] the string to make xml-compliant.
@return [String] the xml-compliant string
# File lib/jamf/utility.rb 457 def escape_xml(string) 458 string.gsub(/&/, '&').gsub(/"/, '"').gsub(/>/, '>').gsub(/</, '<').gsub(/'/, ''') 459 end
Converts an OS Version into an Array
of equal or higher OS versions, up to some non-existant max, hopefully far in the future, currently 20.12.10
This array can then be joined with commas and used as the value of the os_requirements for Packages and Scripts.
It’s unlikely that this method, as written, will still be in use by the release of macOS 20.12.10, but currently thats the upper limit.
Hopefully well before then JAMF will implement a “minimum OS” in Jamf
Pro itself, then we could avoid the inherant limitations in using a method like this.
When the highest maint. release of an OS version is not known, because its the currently released OS version or higher, then this method assumes ‘12’ e.g. ‘10.16.12’, ‘11.12’, ‘12.12’, etc.
Apple has never released more than 11 updates to a version of macOS (that being 10.4), so hopefully 12 is enough
Since Big Sur might report itself as either ‘10.16’ or ‘11.x.x’, this method will allow for both possibilities, and the array will contain whatever iterations needed for both version numbers
@param min_os [String] the mimimum OS version to expand, e.g. “>=10.9.4” or “11.1”
@return [Array] Nearly all potential OS versions from the minimum to 20.12.10
@example
JSS.expand_min_os ">=10.9.4" # => returns this array # ["10.9.4", # "10.9.5", # "10.10.x" # ... # "10.16.x", # "11.x", # "12.x", # ... # "20.x"]
# File lib/jamf/utility.rb 132 def expand_min_os(min_os) 133 min_os = min_os.delete '>=' 134 135 # split the version into major, minor and maintenance release numbers 136 major, minor, maint = min_os.split('.') 137 minor = 'x' if minor.nil? || minor == '0' 138 maint = 'x' if maint.nil? || maint == '0' 139 140 ok_oses = [] 141 142 # Deal with 10.x.x up to 10.16 143 if major == '10' 144 145 # In big sur with SYSTEM_VERSION_COMPAT 146 # set, it will only ever report as `10.16` 147 # So if major is 10 and minor is 16, ignore maint 148 # and start explicitly at '10.16' 149 if minor == '16' 150 ok_oses << '10.16' 151 152 # But for Catalina and below, we need to 153 # expand things out 154 else 155 # e.g. 10.14.x 156 # doesn't expand to anything 157 if maint == 'x' 158 ok_oses << "10.#{minor}.x" 159 160 # e.g. 10.15.5 161 # expand to 10.15.5, 10.15.6, 10.15.7 162 else 163 max_maint_for_minor = OS_TEN_MAXS[minor.to_i] 164 165 (maint.to_i..max_maint_for_minor).each do |m| 166 ok_oses << "#{major}.#{minor}.#{m}" 167 end # each m 168 end # if maint == x 169 170 # now if we started below catalina, account for everything 171 # up to 10.15.x 172 ((minor.to_i + 1)..15).each { |v| ok_oses << "10.#{v}.x" } if minor.to_i < 15 173 174 # and add big sur with SYSTEM_VERSION_COMPAT 175 ok_oses << '10.16' 176 end # if minor == 16 177 178 # now reset these so we can go higher 179 major = '11' 180 minor = 'x' 181 maint = 'x' 182 end # if major == 10 183 184 # if the min os is 11.0.0 or equiv, and we aven't added 10.16 185 # for SYSTEM_VERSION_COMPAT, add it now 186 ok_oses << '10.16' if ['11', '11.x', '11.x.x', '11.0', '11.0.0'].include?(min_os) && !ok_oses.include?('10.16') 187 188 # e.g. 11.x, or 11.x.x 189 # expand to 11.x, 12.x, 13.x, ... 30.x 190 if minor == 'x' 191 ((major.to_i)..MAC_OS_MAXS.keys.max).each { |v| ok_oses << "#{v}.x" } 192 193 # e.g. 11.2.x 194 # expand to 11.2.x, 11.3.x, ... 11.12.x, 195 # 12.x, 13.x, ... 20.x 196 elsif maint == 'x' 197 # first expand the minors out to their max 198 # e.g. 11.2.x, 11.3.x, ... 11.12.x 199 max_minor_for_major = MAC_OS_MAXS[major.to_i] 200 ((minor.to_i)..max_minor_for_major).each do |m| 201 ok_oses << "#{major}.#{m}.x" 202 end # each m 203 204 # then add the majors out to 20 205 ((major.to_i + 1)...MAC_OS_MAXS.keys.max).each { |v| ok_oses << "#{v}.x" } 206 207 # e.g. 11.2.3 208 # expand to 11.2.3, 11.2.4, ... 11.2.10, 209 # 11.3.x, 11.4.x, ... 11.12.x, 210 # 12.x, 13.x, ... 20.x 211 else 212 # first expand the maints out to 10 213 # e.g. 11.2.3, 11.2.4, ... 11.2.10 214 ((maint.to_i)..10).each { |mnt| ok_oses << "#{major}.#{minor}.#{mnt}" } 215 216 # then expand the minors out to their max 217 # e.g. 11.3.x, ... 11.12.x 218 max_minor_for_major = MAC_OS_MAXS[major.to_i] 219 ((minor.to_i + 1)..max_minor_for_major).each { |min| ok_oses << "#{major}.#{min}.x" } 220 221 # then add the majors out to 20 222 ((major.to_i + 1)..MAC_OS_MAXS.keys.max).each { |v| ok_oses << "#{v}.x" } 223 end 224 225 ok_oses 226 end
Given a simple Hash
, convert it to an array of REXML Elements such that each key becomes an element, and its value becomes the text content of that element
@example
my_hash = {:foo => "bar", :baz => :morefoo} xml = JSS.hash_to_rexml_array(my_hash) xml.each{|x| puts x } <foo>bar</foo> <baz>morefoo</baz>
@param hash [Hash{#to_s => to_s}] the Hash
to convert
@return [Array<REXML::Element>] the Array
of REXML elements.
# File lib/jamf/utility.rb 501 def hash_to_rexml_array(hash) 502 raise InvalidDataError, 'Arg. must be a Hash.' unless hash.is_a? Hash 503 504 ary = [] 505 hash.each_pair do |k, v| 506 el = REXML::Element.new k.to_s 507 el.text = v 508 ary << el 509 end 510 ary 511 end
Very handy! lifted from stackoverflow.com/questions/4136248/how-to-generate-a-human-readable-time-range-using-ruby-on-rails
Turns the integer 834756398 into the string “26 years 23 weeks 1 day 12 hours 46 minutes 38 seconds”
@param secs [Integer] a number of seconds
@return [String] a human-readable (English) version of that number of seconds.
# File lib/jamf/utility.rb 665 def humanize_secs(secs) 666 [[60, :second], [60, :minute], [24, :hour], [7, :day], [52.179, :week], [1_000_000_000, :year]].map do |count, name| 667 next unless secs > 0 668 669 secs, n = secs.divmod(count) 670 n = n.to_i 671 "#{n} #{n == 1 ? name : (name.to_s + 's')}" 672 end.compact.reverse.join(' ') 673 end
Given an Array
of Hashes with :id and/or :name keys, return a single REXML element with a sub-element for each item, each of which contains a :name or :id element.
@param list_element [#to_s] the name of the XML element that contains the list. e.g. :computers
@param item_element [#to_s] the name of each XML element in the list, e.g. :computer
@param item_list [Array<Hash>] an Array
of Hashes each with a :name or :id key.
@param content [Symbol] which hash key should be used as the content of if list item? Defaults to :name
@return [REXML::Element] the item list as REXML
@example
comps = [{:id=>2,:name=>'kimchi'},{:id=>5,:name=>'mantis'}] xml = JSS.item_list_to_rexml_list(:computers, :computer , comps, :name) puts xml # output manually formatted for clarity. No newlines in the real xml string <computers> <computer> <name>kimchi</name> </computer> <computer> <name>mantis</name> </computer> </computers> # if content is :id, then, eg. <name>kimchi</name> would be <id>2</id>
# File lib/jamf/utility.rb 545 def item_list_to_rexml_list(list_element, item_element, item_list, content = :name) 546 xml_list = REXML::Element.new list_element.to_s 547 item_list.each do |i| 548 xml_list.add_element(item_element.to_s).add_element(content.to_s).text = i[content] 549 end 550 xml_list 551 end
Scripts and packages can have OS limitations. This method tests a given OS, against a requirement list to see if the requirement is met.
@param requirement The os requirement list, a comma-seprated string
or array of strings of allows OSes. e.g. 10.7, 10.8.5 or 10.9.x
@param processor the os to check, defaults to
the os of the current machine.
@return [Boolean] can this pkg be installed with the processor
given?
# File lib/jamf/utility.rb 262 def os_ok?(requirement, os_to_check = nil) 263 return true if requirement.to_s =~ /none/i 264 return true if requirement.to_s == 'n' 265 266 requirement = JSS.to_s_and_a(requirement)[:arrayform] 267 return true if requirement.empty? 268 269 os_to_check ||= `/usr/bin/sw_vers -productVersion`.chomp 270 271 # convert the requirement array into an array of regexps. 272 # examples: 273 # "10.8.5" becomes /^10\.8\.5$/ 274 # "10.8" becomes /^10.8(.0)?$/ 275 # "10.8.x" /^10\.8\.?\d*$/ 276 req_regexps = requirement.map do |r| 277 if r.end_with?('.x') 278 /^#{r.chomp('.x').gsub('.', '\.')}(\.?\d*)*$/ 279 280 elsif r =~ /^\d+\.\d+$/ 281 /^#{r.gsub('.', '\.')}(.0)?$/ 282 283 else 284 /^#{r.gsub('.', '\.')}$/ 285 end 286 end 287 288 req_regexps.each { |re| return true if os_to_check =~ re } 289 false 290 end
Parse a JSS
Version number into something comparable.
This method returns a Hash
with these keys:
-
:major => the major version, Integer
-
:minor => the minor version, Integor
-
:maint => the revision, Integer (also available as :patch and :revision)
-
:build => the revision,
String
-
:version => a Gem::Version object built from :major, :minor, :revision which can be easily compared with other Gem::Version objects.
NOTE: the :version value ignores build numbers, so comparisons only compare major.minor.maint
@param version a JSS
version number from the API
@return [Hash{Symbol => String
, Gem::Version}] the parsed version data.
# File lib/jamf/utility.rb 570 def parse_jss_version(version) 571 major, second_part, *_rest = version.split('.') 572 raise Jamf::InvalidDataError, 'JSS Versions must start with "x.x" where x is one or more digits' unless major =~ /\d$/ && second_part =~ /^\d/ 573 574 release, build = version.split(/-/) 575 576 major, minor, revision = release.split '.' 577 minor ||= 0 578 revision ||= 0 579 580 { 581 major: major.to_i, 582 minor: minor.to_i, 583 revision: revision.to_i, 584 maint: revision.to_i, 585 patch: revision.to_i, 586 build: build, 587 version: Gem::Version.new("#{major}.#{minor}.#{revision}") 588 } 589 end
Parse a plist into a Ruby data structure. The plist parameter may be a String
containing an XML plist, or a path to a plist file, or it may be a Pathname
object pointing to a plist file. The plist files may be XML or binary.
@param plist[Pathname, String] the plist XML, or the path to a plist file
@param symbol_keys should any Hash
keys in the result be converted
into Symbols rather than remain as Strings?
@return [Object] the parsed plist as a ruby hash,array, etc.
# File lib/jamf/utility.rb 345 def parse_plist(plist, symbol_keys: false) 346 require 'cfpropertylist' 347 348 # did we get a string of xml, or a string pathname? 349 case plist 350 when String 351 return CFPropertyList.native_types(CFPropertyList::List.new(data: plist).value, symbol_keys) if plist.include? '</plist>' 352 353 plist = Pathname.new plist 354 when Pathname 355 true 356 else 357 raise ArgumentError, 'Argument must be a path (as a Pathname or String) or a String of XML' 358 end # case plist 359 360 # if we're here, its a Pathname 361 raise Jamf::MissingDataError, "No such file: #{plist}" unless plist.file? 362 363 CFPropertyList.native_types(CFPropertyList::List.new(file: plist).value, symbol_keys) 364 end
a wrapper around Time.parse that returns nil for nil, zero, and empty values.
# File lib/jamf/utility.rb 327 def parse_time(a_datetime) 328 return nil if NIL_DATES.include? a_datetime 329 330 Time.parse a_datetime.to_s 331 end
Scripts and packages can have processor limitations. This method tests a given processor, against a requirement to see if the requirement is met.
@param requirement The processor requirement.
either 'ppc', 'x86', or some variation on "none", nil, or empty
@param processor the processor to check, defaults to
the processor of the current machine. Any flavor of intel is (i486, i386, x86-64, etc) is treated as "x86"
@return [Boolean] can this pkg be installed with the processor
given?
# File lib/jamf/utility.rb 242 def processor_ok?(requirement, processor = nil) 243 return true if requirement.to_s.empty? || requirement =~ /none/i 244 245 processor ||= `/usr/bin/uname -p` 246 requirement == (processor.to_s.include?('86') ? 'x86' : 'ppc') 247 end
Prompt for a password in a terminal.
@param message [String] the prompt message to display
@return [String] the text typed by the user
# File lib/jamf/utility.rb 622 def prompt_for_password(message) 623 begin 624 $stdin.reopen '/dev/tty' unless $stdin.tty? 625 $stderr.print "#{message} " 626 system '/bin/stty -echo' 627 pw = $stdin.gets.chomp("\n") 628 puts 629 ensure 630 system '/bin/stty echo' 631 end # begin 632 pw 633 end
Retrive one or all lines from whatever was piped to standard input.
Standard input is read completely the first time this method is called and the lines are stored as an Array
in the module var @stdin_lines
@param line which line of stdin is being retrieved.
The default is zero (0) which returns all of stdin as a single string.
@return [String, nil] the requested ling of stdin, or nil if it doesn’t exist.
# File lib/jamf/utility.rb 607 def stdin(line = 0) 608 @stdin_lines ||= ($stdin.tty? ? [] : $stdin.read.lines.map { |l| l.chomp("\n") }) 609 610 return @stdin_lines.join("\n") if line <= 0 611 612 idx = line - 1 613 @stdin_lines[idx] 614 end
@return [Boolean] is this code running as root?
# File lib/jamf/utility.rb 593 def superuser? 594 Process.euid.zero? 595 end
Given a list of data as a comma-separated string, or an Array
of strings, return a Hash
with both versions.
Some parts of the JSS
require lists as comma-separated strings, while often those data are easier work with as arrays. This method is a handy way to get either form when given either form.
@param somedata [String, Array] the data to parse, of either class,
@return [Hash{:stringform => String
, :arrayform => Array}] the data as both comma-separated String
and Array
@example
JSS.to_s_and_a "foo, bar, baz" # Hash => {:stringform => "foo, bar, baz", :arrayform => ["foo", "bar", "baz"]} JSS.to_s_and_a ["foo", "bar", "baz"] # Hash => {:stringform => "foo, bar, baz", :arrayform => ["foo", "bar", "baz"]}
# File lib/jamf/utility.rb 308 def to_s_and_a(somedata) 309 case somedata 310 when nil 311 valstr = '' 312 valarr = [] 313 when String 314 valstr = somedata 315 valarr = somedata.split(/,\s*/) 316 when Array 317 valstr = somedata.join ', ' 318 valarr = somedata 319 else 320 raise Jamf::InvalidDataError, 'Input must be a comma-separated String or an Array of Strings' 321 end # case 322 { stringform: valstr, arrayform: valarr } 323 end
Convert any ruby data to an XML plist.
NOTE: Binary data is tricky. Easiest way is to pass in a Pathname
or IO object (anything that responds to ‘read` and returns a bytestring) and then the CFPropertyList.guess method will read it and convert it to a Plist <data> element with base64 encoded data. For more info, see CFPropertyList.guess
@param data [Object] the data to be converted, usually a Hash
@return [String] the object converted into an XML plist
# File lib/jamf/utility.rb 380 def xml_plist_from(data) 381 require 'cfpropertylist' 382 plist = CFPropertyList::List.new 383 plist.value = CFPropertyList.guess(data, convert_unknown_to_string: true) 384 plist.to_str(CFPropertyList::List::FORMAT_XML) 385 end