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.12

12 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.7

12 is the default for the current OS and higher (and hoping apple doesn’t release 10.16.13)

Public Instance Methods

api_object_class(name) click to toggle source

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
api_object_names() click to toggle source

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
array_to_rexml_array(element, list) click to toggle source

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
devmode(setting) click to toggle source

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
devmode?() click to toggle source

is devmode currently on?

@return [Boolean]

    # File lib/jamf/utility.rb
651 def devmode?
652   @devmode
653 end
epoch_to_time(epoch) click to toggle source

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
escape_xml(string) click to toggle source

Given a string of xml element text, escape any characters that would make XML unhappy.

* & => &amp;
* " => &quot;
* < => &lt;
* > => &gt;
* ' => &apos;

@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(/&/, '&amp;').gsub(/"/, '&quot;').gsub(/>/, '&gt;').gsub(/</, '&lt;').gsub(/'/, '&apos;')
459 end
expand_min_os(min_os) click to toggle source

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
hash_to_rexml_array(hash) click to toggle source

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
humanize_secs(secs) click to toggle source

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
item_list_to_rexml_list(list_element, item_element, item_list, content = :name) click to toggle source

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
os_ok?(requirement, os_to_check = nil) click to toggle source

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_jss_version(version) click to toggle source

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_plist(plist, symbol_keys: false) click to toggle source

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
parse_time(a_datetime) click to toggle source

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
processor_ok?(requirement, processor = nil) click to toggle source

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_password(message) click to toggle source

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
stdin(line = 0) click to toggle source

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
superuser?() click to toggle source

@return [Boolean] is this code running as root?

    # File lib/jamf/utility.rb
593 def superuser?
594   Process.euid.zero?
595 end
to_s_and_a(somedata) click to toggle source

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
xml_plist_from(data) click to toggle source

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