class QB::Role
Contains info on a QB
role.
Definitions
¶ ↑
Definitions
¶ ↑
Definitions
¶ ↑
Definitions
¶ ↑
Constants
- BUILTIN_PATH
“Factory defaults” that {QB::Role::PATH} is initialized to, and what it gets reset to when {QB::Role.reset_path!} is called.
Read the {QB::Role::PATH} docs for details on how
QB
role paths work.This value is deeply frozen, and you should not attempt to change it - mess with {QB::Role::PATH} instead.
@return [Array<String>]
- PATH
Array of string paths to directories to search for roles or paths to `ansible.cfg` files to look for an extract role paths from.
Value is a duplicate of the frozen {QB::Role::BUILTIN_PATH}. You can reset to those values at any time via {QB::Role.reset_path!}.
For the moment at least you can just mutate this value like you would `$LOAD_PATH`:
QB::Role::PATH.unshift '~/where/some/roles/be' QB::Role::PATH.unshift '~/my/ansible.cfg'
The paths are searched from first to last.
WARNING
Search is **deep** - don't point this at large directory trees and expect any sort of reasonable performance (any directory that contains `node_modules` is usually a terrible idea for instance).
@return [Array<String>]
Attributes
@!attribute [r] display_path
the path to the role that we display. we only show the directory name for QB
roles, and use {QB::Util.compact_path} to show `.` and `~` for paths relative to the current directory and home directory, respectively.
@return [Pathname]
@!attribute [r] meta_path
@return [String, nil] the path qb metadata was load from. `nil` if it's never been loaded or doesn't exist.
@!attribute [r] name
@return [String] the role's ansible "name", which is it's directory name.
@!attribute [r] path
@return [Pathname] location of the role directory.
Public Class Methods
All {QB::Role} found in search path.
Does it's best to remove duplicates that end up being reached though multiple search paths (happens most in development).
@return [Array<QB::Role>]
# File lib/qb/role.rb, line 60 def self.available self.search_path. select {|search_dir| # make sure it's there (and a directory) search_dir.directory? }. map {|search_dir| ['', '.yml', '.yaml'].flat_map { |ext| Pathname.glob(search_dir.join '**', 'meta', "qb#{ ext }"). map {|meta_path| [meta_path.dirname.dirname, search_dir: search_dir] } } }. flatten( 1 ). map { |args| QB::Role.new *args }. uniq end
Do our best to figure out a role name from a path (that might not exist).
We needs this when we're creating a role.
@param [String | Pathname] path
@return [String]
# File lib/qb/role/name.rb, line 61 def self.default_name_for path resolved_path = QB::Util.resolve path # Find the first directory in the search path that contains the path, # if any do. # # It *could* be in more than one in funky situations like overlapping # search paths or link silliness, but that doesn't matter - we consider # the first place we find it to be the relevant once, since the search # path is most-important-first. # search_dir = search_path.find { |pathname| resolved_path.fnmatch? ( pathname / '**' ).to_s } if search_dir.nil? # It's not in any of the search directories # # If it has 'roles' as a segment than use what's after the last occurrence # of that (unless there isn't anything). # segments = resolved_path.to_s.split File::SEPARATOR if index = segments.rindex( 'roles' ) name_segs = segments[( index + 1 )..( -1 )] unless name_segs.empty? return File.join name_segs end end # Ok, that didn't work... just return the basename I guess... return File.basename resolved_path end # it's in the search path, return the relative path from the containing # search dir to the resolved path (string version of it). resolved_path.relative_path_from( search_dir ).to_s end
Get the include path for an included role based on the option metadata that defines the include and the current include path.
@param role [Role]
the role to include.
@param option_meta [Hash]
the entry for the option in qb.yml
@param current_include_path [Array<string>]
@return [Array<string>]
include path for the included role.
# File lib/qb/role.rb, line 136 def self.get_include_path role, option_meta, current_include_path new_include_path = if option_meta.key? 'as' case option_meta['as'] when nil, false # include it in with the parent role's options current_include_path when String current_include_path + [option_meta['as']] else raise QB::Role::MetadataError.new, "bad 'as' value: #{ option_meta.inspect }" end else current_include_path + [role.namespaceless] end end
Get an array of {QB::Role} that match an input string.
This is the meat of whats needed to support {QB::Role.require}.
How it works is… tricky. Read the comments and play around with it is the bast I can offer right now.
@param [String] input
The input string to match against role paths and names. Primarily what the user typed after `qb run` on the CLI.
@return [Array<QB::Role>]
# File lib/qb/role/matches.rb, line 37 def self.matches input # keep this here to we don't re-gen every loop available = self.available # first off, see if input matches any relative paths exactly available.each {|role| return [role] if role.display_path.to_s == input } # create an array of "separator" variations to try *exact* matching # against. in order of preference: # # 1. exact input # - this means if you ended up with roles that actually *are* # differentiated by '_/-' differences (which, IMHO, is a # horrible fucking idea), you can get exactly what you ask for # as a first priority # 2. input with '-' changed to '_' # - prioritized because convention is to underscore-separate # role names. # 3. input with '_' changed to '-' # - really just for convenience's sake so you don't really have to # remember what separator is used. # separator_variations = [ input, input.gsub('-', '_'), input.gsub('_', '-'), ] # {QB::Role} method names to check against, from highest to lowest # precedence method_names = [ # 1. The path we display to the user. This comes first because typing # in exactly what they see should always work. :display_name, # 2. The role's full name (with namespace) as it is likely to be used # in Ansible :name, # 3. The part of the role after the namespace, which is far less # specific, but nice short-hand if it's unique :namespaceless ] # 1. Exact matches (allowing `-`/`_` substitution) # # Highest precedence, guaranteeing that exact verbatim matches will # always work (or that's the intent). # method_names.each { |method_name| separator_variations.each { |variation| matches = available.select { |role| role.public_send( method_name ) == variation } return matches unless matches.empty? } } # 2. Prefix matches # # Do any of {#display_path}, {#name} or {#namespaceless} or start with # the input pattern? # method_names.each { |method_name| separator_variations.each { |variation| matches = available.select { |role| role.public_send( method_name ).start_with? variation } return matches unless matches.empty? } } # 3. Word slice full matches # # Split the {#display_name} and input first by `/` and `.` segments, # then {String#downcase} each segments and split it into words (using # {NRSER.words}). # # Then see if the input appears in the role name. # # We test only {#display_name} because it should always contain # {#name} and {#namesaceless}, so it's pointless to test the other # two after it). # word_parse = ->( string ) { string.split( /[\/\.]/ ).map { |seg| seg.downcase.words } } input_parse = word_parse.call input exact_word_slice_matches = available.select { |role| word_parse.call( role.display_name ).slice? input_parse } return exact_word_slice_matches unless exact_word_slice_matches.empty? # 4. Word slice prefix matches # # Same thing as (3), but do a prefix match instead of the entire # words. # name_word_matches = available.select { |role| word_parse.call( role.display_name ). slice?( input_parse ) { |role_words, input_words| # Use a custom match block to implement prefix matching # # We want to match if each input word is the start of the # corresponding role name word # if role_words.length >= input_words.length input_words.each_with_index.all? { |input_word, index| role_words[index].start_with? input_word } else false end } QB::Util.words_start_with? role.display_path.to_s, input } return name_word_matches unless name_word_matches.empty? # nada [] end
# File lib/qb/role/name.rb, line 107 def self.namespace_for name *namespace_segments, last = name.split File::Separator namespace_segments << last.split('.').first if last.include?('.') if namespace_segments.empty? nil else File.join *namespace_segments end end
# File lib/qb/role/name.rb, line 120 def self.namespaceless_for name File.basename( name ).split('.', 2).last end
Instantiate a Role
.
@param [String|Pathname] path
location of the role directory
@param [nil, Pathname] search_dir
Directory in {QB::Role.search_path} that the role was found in. Used to figure out it's name correctly when using directory-structure namespacing.
# File lib/qb/role.rb, line 216 def initialize path, search_dir: nil @path = if path.is_a?(Pathname) then path else Pathname.new(path) end # check it... unless @path.exist? raise Errno::ENOENT.new @path.to_s end unless @path.directory? raise Errno::ENOTDIR.new @path.to_s end @display_path = self.class.to_display_path @path @meta_path = if (@path + 'meta' + 'qb').exist? @path + 'meta' + 'qb' elsif (@path + 'meta' + 'qb.yml').exist? @path + 'meta' + 'qb.yml' else raise Errno::ENOENT.new "#{ @path.join('meta').to_s }/[qb|qb.yml]" end if search_dir.nil? @name = @path.to_s.split(File::SEPARATOR).last else @name = @path.relative_path_from(search_dir).to_s end end
Find exactly one matching role for the input string or raise.
Where we look is determined by {QB::Role::PATH} via {QB::Role.search_path}.
@param [String] input
Input string term used to search (what we got off the CLI args).
@return [QB::Role]
The single matching role.
@raise [QB::Role::NoMatchesError]
If we didn't find any matches.
@raise [QB::Role::MultipleMatchesError]
If we matched more than one role.
# File lib/qb/role.rb, line 96 def self.require input as_pathname = Pathname.new(input) # allow a path to a role dir if role_dir? as_pathname return QB::Role.new as_pathname end matches = self.matches input role = case matches.length when 0 raise QB::Role::NoMatchesError.new input when 1 matches[0] else raise QB::Role::MultipleMatchesError.new input, matches end QB.debug "role match" => role role end
Reset {QB::Role::PATH} to the original built-in values in {QB::Role::BUILTIN_PATH}.
Created for testing but might be useful elsewhere as well.
@return [Array<String>]
The reset {QB::Role::PATH}.
# File lib/qb/role/search_path.rb, line 139 def self.reset_path! PATH.clear BUILTIN_PATH.each { |path| PATH << path } PATH end
true if pathname is a QB
role directory.
# File lib/qb/role.rb, line 45 def self.role_dir? pathname # must be a directory pathname.directory? && # and must have meta/qb.yml or meta/qb file ['qb.yml', 'qb'].any? {|filename| pathname.join('meta', filename).file?} end
Gets the array of paths to search for QB
roles based on {QB::Role::PATH} and the working directory at the time it's called.
QB
then uses the returned value to figure out what roles are available.
The process:
-
Resolve relative paths against the working directory.
-
Load up any `ansible.cfg` files on the path and add any `roles_path` they define where the `ansible.cfg` entry was in {QB::Role::PATH}.
@return [Array<Pathname>]
Directories to search for QB roles.
# File lib/qb/role/search_path.rb, line 161 def self.search_path QB::Role::PATH. map { |path| if QB::Ansible::ConfigFile.end_with_config_file?(path) if File.file?(path) QB::Ansible::ConfigFile.new(path).defaults.roles_path end else QB::Util.resolve path end }. flatten. reject(&:nil?) end
The path we display in the CLI, see {#display_path}.
@param [Pathname | String] path
input path to transform.
@return [Pathname]
path to display.
# File lib/qb/role.rb, line 162 def self.to_display_path path if path.realpath.start_with? QB::GEM_ROLES_DIR path.realpath.sub (QB::GEM_ROLES_DIR.to_s + '/'), '' else QB::Util.contract_path path end end
Public Instance Methods
# File lib/qb/role.rb, line 585 def == other other.is_a?(self.class) && other.path.realpath == path.realpath end
should qb ask for an ansible vault password?
@see docs.ansible.com/ansible/playbooks_vault.html
@return [Boolean]
`true` if qb should ask for a vault password.
# File lib/qb/role.rb, line 479 def ask_vault_pass? !!@meta['ask_vault_pass'] end
Check the role's requirements.
@return [nil]
@raise [QB::AnsibleVersionError]
If the version of Ansible found does not satisfy the role's requirements.
@raise [QB::QBVersionError]
If the the version of QB we're running does not satisfy the role's requirements.
# File lib/qb/role.rb, line 528 def check_requirements if ansible_req = requirements['ansible'] unless ansible_req.satisfied_by? QB.ansible_version raise QB::AnsibleVersionError.squished <<-END QB #{ QB::VERSION } requires Ansible #{ ansible_req }, found version #{ QB.ansible_version } at #{ `which ansible` } END end end if qb_req = requirements.dig( 'gems', 'qb' ) unless qb_req.satisfied_by? QB.gem_version raise QB::QBVersionError.squished <<-END Role #{ self } requires QB #{ qb_req }, using QB #{ QB.gem_version } from #{ QB::ROOT }. END end end nil end
@return [Hash<String, *>]
default `ansible-playbook` CLI options from role qb metadata. Hash of option name to value.
# File lib/qb/role.rb, line 497 def default_ansible_options meta_or 'ansible_options', {} end
Gets the default `qb_dir` value, raising an error if the role doesn't define how to get one or there is a problem getting it.
It uses a “strategy” value found at the 'default_dir' key in the role's QB
metadata (in `<role_path>/meta/qb.yml` or returned by a `<role_path>/meta/qb` executable).
See the {file:doc/qb_roles/metadata/default_dir.md default_dir
} documentation for details on the accepted strategy values.
@param [String | Pathname] cwd:
The working directory the CLI command was run in.
@param [Hash<String, QB::Options::Option>] options:
The role options (from {QB::Options#role_options}). TODO rename this.
@return [Pathname]
The directory to target.
@raise
When we can't determine a directory due to role meta settings or target system state.
# File lib/qb/role/default_dir.rb, line 71 def default_dir cwd, options logger.debug "CALLING default_dir", role: self.instance_variables.assoc_to { |key| self.instance_variable_get key }, cwd: cwd, options: options default_dir_for( strategy: self.meta['default_dir'], cwd: cwd, options: options ).to_pn end
gets the role variable defaults from defaults/main.yml, or {}
# File lib/qb/role.rb, line 362 def defaults @defaults || load_defaults end
@return [String]
The `description` value from the role's QB metadata, or '' if it doesn't have one
# File lib/qb/role.rb, line 554 def description meta['description'].to_s end
Just a string version of {#display_path}
# File lib/qb/role.rb, line 252 def display_name display_path.to_s end
# File lib/qb/role.rb, line 432 def examples @meta['examples'] end
format the `meta.examples` hash into a string suitable for cli output.
@return [String]
the CLI-formatted examples.
# File lib/qb/role.rb, line 442 def format_examples examples. map {|title, body| [ "#{ title }:", body.lines.map {|l| # only indent non-empty lines # makes compacting newline sequences easier (see below) if l.match(/^\s*$/) l else ' ' + l end }, '' ] }. flatten. join("\n"). # compact newline sequences gsub(/\n\n+/, "\n\n") end
Test if the {QB::Role} uses a directory argument (that gets assigned to the `qb_dir` variable in Ansible).
@return [Boolean]
# File lib/qb/role.rb, line 489 def has_dir_arg? meta['default_dir'] != false end
Language Inter-Op
# File lib/qb/role.rb, line 580 def hash path.realpath.hash end
loads the defaults from vars/main.yml and defaults/main.yml, caching by default. vars override defaults values.
# File lib/qb/role.rb, line 337 def load_defaults cache = true defaults_path = @path + 'defaults' + 'main.yml' defaults = if defaults_path.file? YAML.load(defaults_path.read) || {} else {} end vars_path = @path + 'vars' + 'main.yml' vars = if vars_path.file? YAML.load(vars_path.read) || {} else {} end defaults = defaults.merge! vars if cache @defaults = defaults end defaults end
load qb metadata from meta/qb.yml or from executing meta/qb and parsing the YAML written to stdout.
if `cache` is true caches it as `@meta`
# File lib/qb/role.rb, line 266 def load_meta cache = true meta = if @meta_path.extname == '.yml' contents = begin @meta_path.read rescue Exception => error raise QB::Role::MetadataError, "Failed to read metadata file at #{ @meta_path.to_s }, " + "error: #{ error.inspect }" end begin YAML.load(contents) || {} rescue Exception => error raise QB::Role::MetadataError, "Failed to load metadata YAML from #{ @meta_path.to_s }, " + "error: #{ error.inspect }" end else YAML.load(Cmds.out!(@meta_path.realpath.to_s)) || {} end if cache @meta = meta end meta end
@return [Hash{String => Object}]
the QB metadata for the role.
# File lib/qb/role.rb, line 297 def meta @meta || load_meta end
if the exe should auto-make the directory. this is nice for most roles but some need it to be missing
# File lib/qb/role.rb, line 372 def mkdir !!meta_or('mkdir', true) end
# File lib/qb/role/name.rb, line 133 def namespaceless self.class.namespaceless_for @name end
get the options from the metadata, defaulting to [] if none defined
# File lib/qb/role.rb, line 314 def option_metas meta_or ['options', 'opts', 'vars'], [] end
@return [Array<QB::Options::Option>
an array of Option for the role, including any included roles.
# File lib/qb/role.rb, line 322 def options include_path = [] option_metas.map {|option_meta| if option_meta.key? 'include' role_name = option_meta['include'] role = QB::Role.require role_name role.options QB::Role.get_include_path(role, option_meta, include_path) else QB::Options::Option.new self, option_meta, include_path end }.flatten end
# File lib/qb/role.rb, line 257 def options_key display_name end
examples text
# File lib/qb/role.rb, line 466 def puts_examples return unless examples puts "\n" + format_examples + "\n" end
Parsed tree structure of version requirements of the role from the `requirements` value in the QB
meta data.
@return [Hash]
Tree where the leaves are {Gem::Requirement}.
# File lib/qb/role.rb, line 508 def requirements @requirements ||= NRSER.map_leaves( meta_or 'requirements', {'gems' => {}} ) { |key_path, req_str| Gem::Requirement.new req_str } end
# File lib/qb/role.rb, line 366 def save_options !!meta_or('save_options', true) end
Short summary pulled from the role description - first line if it's multi-line, or first sentence if it's a single line.
Will be an empty string if the role doesn't have a description.
@return [String]
# File lib/qb/role.rb, line 566 def summary description.lines.first.thru { |line| if line line.split( '. ', 2 ).first else '' end } end
@return [String]
{QB::Role#display_path}
# File lib/qb/role.rb, line 595 def to_s @display_path.to_s end
@return [String]
usage information formatted as plain text for the CLI.
# File lib/qb/role.rb, line 379 def usage # Split up options by required and optional. required_options = [] optional_options = [] options.each { |option| if option.required? required_options << option else optional_options << option end } parts = ['qb [run]', name] required_options.each { |option| parts << option.usage } unless optional_options.empty? parts << '[OPTIONS]' end if has_dir_arg? parts << 'DIRECTORY' end parts.join ' ' end
gets the variable prefix that will be appended to cli options before passing them to the role. defaults to `#namespaceless` unless specified in meta.
# File lib/qb/role.rb, line 305 def var_prefix # ugh, i was generating meta/qb.yml files that set 'var_prefix' to # `null`, but it would be nice to # meta_or 'var_prefix', namespaceless end
Protected Instance Methods
Internal, possibly recursive method that actually does the work of figuring out the directory value.
Recurs when the meta value is an array, trying each of the entries in sequence, returning the first to succeed, and raising if they all fail.
@param [nil | false | String | Hash | Array] strategy
Instruction for how to determine the directory value. See the {file:doc/qb_roles.md#default_dir default_dir} for a details on recognized values.
@return [return_type]
@todo Document return value.
# File lib/qb/role/default_dir.rb, line 105 def default_dir_for strategy:, cwd:, options: case strategy when nil # there is no get_dir info in meta/qb.yml, can't get the dir raise QB::UserInputError.new binding.erb <<-END No default directory for role <%= self.name %> Role <%= self.name %> does not provide a default target directory (used to populate the `qb_dir` Ansible variable). You must provide one via the CLI like qb run <%= self.name %> DIRECTORY or, if you are the developer of the <%= self.name %> role, set a non-null value for the 'default_dir' key in <%= self.meta_path %> END when false # this method should not get called when the strategy is `false` (an # entire section is skipped in exe/qb when `default_dir = false`) raise QB::StateError.squished <<-END role does not use default directory (meta/qb.yml:default_dir = false) END when 'git_root' logger.debug "returning the git root relative to cwd" NRSER.git_root cwd when 'cwd' logger.debug "returning current working directory" cwd when Hash logger.debug "qb meta option is a Hash" unless strategy.length == 1 raise "#{ meta_path.to_s }:default_dir invalid: #{ strategy.inspect }" end hash_key, hash_value = strategy.first case hash_key when 'exe' exe_path = hash_value # supply the options to the exe so it can make work off those values # if it wants. exe_input_data = Hash[ options.map {|option| [option.cli_option_name, option.value] } ] unless exe_path.start_with?('~') || exe_path.start_with?('/') exe_path = File.join(self.path, exe_path) debug 'exe path is relative, basing off role dir', exe_path: exe_path end debug "found 'exe' key, calling", exe_path: exe_path, exe_input_data: exe_input_data Cmds.chomp! exe_path do JSON.dump exe_input_data end when 'find_up' rel_path = hash_value unless rel_path.is_a? String raise "find_up relative path or glob must be string, found #{ rel_path.inspect }" end logger.debug "found 'find_up' strategy", rel_path: rel_path cwd.to_pn.find_up! rel_path when 'from_role' # Get the value from another role, presumably one this role includes default_dir_for \ strategy: QB::Role.require( hash_value ).meta['default_dir'], cwd: cwd, options: options else raise QB::Role::MetadataError.new binding.erb <<-END Bad key <%= hash_key.inspect %> in 'default_dir' value Metadata for role <%= name %> read from <%= self.meta_path.to_s %> contains an invalid default directory strategy <%= strategy.pretty_inspect %> The key <%= hash_key.inspect %> does not correspond to a recognized form. Valid forms are: 1. {exe: FILEPATH} 2. {file_up: FILEPATH} 3. {from_role: ROLE} END end when Array strategy.try_find do |candidate| default_dir_for strategy: candidate, cwd: cwd, options: options end else raise QB::Role::MetadataError.new binding.erb <<-END bad default_dir strategy: <%= strategy %> END end
Private Instance Methods
get the value at the first found of the keys or the default.
`nil` (`null` in yaml files) are treated like they're not there at all. you need to use `false` if you want to tell QB
not to do something.
@param [String | Symbol | Array<String | Symbol>] keys
Single
@return [Object]
# File lib/qb/role.rb, line 613 def meta_or keys, default keys.as_array.map(&:to_s).each do |key| return meta[key] unless meta[key].nil? end # We didn't find anything (that wasn't explicitly or implicitly `nil`) default end
Find a non-null/nil value for one of `keys` or raise an error.
@param [String | Symbol | Array<String | Symbol>] keys
Possible key names. They will be searched in order and first non-null/nil value returned.
@return [Object]
@raise [QB::Role::MetadataError]
If none of `keys` are found.
# File lib/qb/role.rb, line 634 def need_meta keys keys = keys.as_array.map(&:to_s) keys.each do |key| return meta[key] unless meta[key].nil? end raise QB::Role::MetadataError.squished <<-END Expected metadata for role #{ self } to define (non-null) value for one of keys #{ keys.inspect }. END end