Module: QB::CLI

Includes:
NRSER::Log::Mixin
Defined in:
lib/qb/cli/dev.rb,
lib/qb/cli.rb,
lib/qb/cli/run.rb,
lib/qb/cli/help.rb,
lib/qb/cli/list.rb,
lib/qb/cli/play.rb,
lib/qb/cli/setup.rb

Overview

Definitions

Defined Under Namespace

Modules: Dev

Constant Summary collapse

DEBUG_ARGS =

CLI args that common to all commands that enable debug output

Returns:

  • (Array<String>)
['-D', '--DEBUG'].freeze
DEFAULT_TERMINAL_WIDTH =

Default terminal line width to use if we can't figure it out dynamically.

Returns:

  • (Fixnum)
80

Class Method Summary collapse

Class Method Details

.ask(name:, description: nil, type:, default: NRSER::NO_ARG) ⇒ return_type

TODO:

Document ask method.

Returns @todo Document return value.

Parameters:

  • arg_name (type)

    @todo Add name param description.

Returns:

  • (return_type)

    @todo Document return value.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/qb/cli.rb', line 68

def self.ask name:,
        description: nil,
        type:,
        default: NRSER::NO_ARG
  puts
    
  value = loop do
    
    puts "Enter value for #{ name }"
    
    if description
      puts description.indent
    end
    
    puts "TYPE #{ type.to_s }".indent
    
    if default
      puts "DEFAULT #{ default.to_s }".indent
    end
    
    $stdout.write '> '
    
    value = gets.chomp
    
    QB.debug "User input", value
    
    if value == '' && default != NRSER::NO_ARG
      puts "        \n        Using default value \#{ default.to_s }\n        \n      END\n      \n      return default\n    end\n    \n    begin\n      type.from_s value\n    rescue TypeError => e\n      puts <<-END.dedent\n        Input value \#{ value.inspect } failed to satisfy type\n        \n            \#{ type.to_s }\n        \n      END\n    else\n      break value\n    end\n    \n  end # loop\n  \n  puts \"Using value \#{ value.inspect }\"\n  \n  return value\n  \nend\n".dedent

.ask_for_option(role:, option:) ⇒ Object



126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/qb/cli.rb', line 126

def self.ask_for_option role:, option:
  default = if role.defaults.key?(option.var_name)
    role.defaults[option.var_name]
  elsif option.required?
    NRSER::NO_ARG
  else
    nil
  end
  
  ask name: option.name,
      description: option.description,
      default: default
      # type:
end

.ask_for_options(role:, options:) ⇒ Object



142
143
144
145
146
# File 'lib/qb/cli.rb', line 142

def self.ask_for_options role:, options:
  options.select { |opt| opt.value.nil? }.each { |option|
    ask_for_option role: role, option: option
  }
end

.dev(cmd, *args) ⇒ Object



112
113
114
115
116
117
118
119
120
121
# File 'lib/qb/cli/dev.rb', line 112

def self.dev cmd, *args
  case cmd
  when 'serve', 'server'
    Dev.serve *args.rest
  when 'req'
    Dev.req *args.rest
  else
    raise "bad .dev subcmd: #{ cmd }"
  end
end

.dynamic_widthObject

Calculate the dynamic width of the terminal



172
173
174
# File 'lib/qb/cli.rb', line 172

def self.dynamic_width
  @dynamic_width ||= (dynamic_width_stty.nonzero? || dynamic_width_tput)
end

.dynamic_width_sttyObject



177
178
179
# File 'lib/qb/cli.rb', line 177

def self.dynamic_width_stty
  `stty size 2>/dev/null`.split[1].to_i
end

.dynamic_width_tputObject



182
183
184
# File 'lib/qb/cli.rb', line 182

def self.dynamic_width_tput
  `tput cols 2>/dev/null`.to_i
end

.help(args = []) ⇒ 1

TODO:

We should have more types of help.

Show the help message.

Returns:

  • (1)

    Error exit status - we don't want qb ... && ... to move on to the second command when we end up falling back to help.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/qb/cli/help.rb', line 27

def self.help args = []
   = if QB.gemspec. && !QB.gemspec..empty?
    "metadata:\n" + QB.gemspec..map {|key, value|
      "  #{ key }: #{ value }"
    }.join("\n")
  end
  
  puts "version: \#{ QB::VERSION }\n\n\#{ metadata }\n\nsyntax:\n\nqb ROLE [OPTIONS] DIRECTORY\n\nuse `qb ROLE -h` for role options.\n\navailable roles:\n\n  END\n  puts QB::Role.available\n  puts\n  \n  return 1\nend\n"

.list(pattern = nil) ⇒ 1

TODO:

We should have more types of help.

List available roles.

Examples:


qb list --user
qb list -u
qb list --local
qb list -l
qb list --system
qb list -s
qb list --path=:system
qb list --path=./roles
qb list -p ./roles
qb list gem

Returns:

  • (1)

    Error exit status - we don't want qb ... && ... to move on to the second command when we end up falling back to help.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/qb/cli/list.rb', line 34

def self.list pattern = nil
  roles = if pattern
    QB::Role.matches pattern
  else
    QB::Role.available
  end
  
  name_col_width = roles.map { |r| r.display_name.length }.max + 2
  
  roles.each { |role|
    summary = role.summary.truncate QB::CLI.terminal_width - name_col_width
    
    puts ("%-#{ name_col_width }s" % role.display_name) + summary
  }
  
  puts
  
  return 0
end

.play(args) ⇒ Fixnum

Play an Ansible playbook (like state.yml) in the QB environment (sets up path env vars, IO streams, etc.).

Parameters:

  • args (Array<String>)

    CLI arguments to use.

Returns:

  • (Fixnum)

    The ansible-playbook command exit code.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/qb/cli/play.rb', line 28

def self.play args
  if args.empty?
    raise "Need path to playbook in first arg."
  end
  
  playbook_path = QB::Util.resolve args[0]
  
  unless playbook_path.file?
    raise "Can't find Ansible playbook at `#{ playbook_path.to_s }`"
  end
  
  # By default, we won't change directories to run the command.
  chdir = nil
  
  # See if there is an Ansible config in the parent directories
  ansible_cfg_path = QB::Util.find_up \
    QB::Ansible::ConfigFile::FILE_NAME,
    playbook_path.dirname,
    raise_on_not_found: false
  
  # If we did find an Ansible config, we're going to want to run in that
  # directory and add it to the role search path so that we merge it's
  # values into our env vars (otherwise they would override the config
  # values).
  unless ansible_cfg_path.nil?
    QB::Role::PATH.unshift ansible_cfg_path.dirname
    chdir = ansible_cfg_path.dirname
  end
  
  cmd = QB::Ansible::Cmd::Playbook.new \
    chdir: chdir,
    playbook_path: playbook_path
  
  status = cmd.stream
  
  if status != 0
    $stderr.puts "ERROR ansible-playbook failed."
  end
  
  exit status
  
end

.run(args) ⇒ Fixnum

Run a QB role.

Parameters:

  • args (Array<String>)

    CLI args to work with.

Returns:

  • (Fixnum)

    Exit status code from ansible-playbook command, unless we invoked help or error'd out in another way before the run (in which case 1 is returned).



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/qb/cli/run.rb', line 28

def self.run args
  role_arg = args.shift
  QB.debug "role arg" => role_arg
  
  begin
    role = QB::Role.require role_arg
  rescue QB::Role::NoMatchesError => e
    puts "ERROR - #{ e.message }\n\n"
    # exits with status code 1
    return help
  rescue QB::Role::MultipleMatchesError => e
    puts "ERROR - #{ e.message }\n\n"
    return 1
  end
  
  role.check_requirements
  
  options = QB::Options.new role, args
  
  QB.debug "Role options set on cli",
    role: options.role_options.reject { |k, o| o.value.nil? }
  
  QB.debug "QB options", options.qb.dup
  QB.debug "Ansible options", options.ansible.dup
  
  cwd = Dir.getwd
  
  dir = nil
  
  if role.has_dir_arg?
    # get the target dir
    dir = case args.length
    when 0
      # in this case, a dir has not been provided
      # 
      # in some cases (like projects) the dir can be figured out in other ways:
      # 
      
      if options.ask?
        default = begin
          role.default_dir cwd, options.role_options
        rescue QB::UserInputError => e
          NRSER::NO_ARG
        end
        
        QB::CLI.ask name: "target directory (`qb_dir`)",
                    type: t.non_empty_str,
                    default: default
        
      else
        role.default_dir cwd, options.role_options
      end
      
    when 1
      # there is a single positional arg, which is used as dir
      args[0]
      
    else
      # there are multiple positional args, which is not allowed
      raise "can't supply more than one argument: #{ args.inspect }"
      
    end
    
    QB.debug "input_dir", dir
    
    # normalize to expanded path (has no trailing slash)
    dir = File.expand_path dir
    
    QB.debug "normalized_dir", dir
    
    # create the dir if it doesn't exist (so don't have to cover this in
    # every role)
    if role.mkdir
      FileUtils.mkdir_p dir unless File.exists? dir
    end
  
    saved_options_path = Pathname.new(dir) + '.qb-options.yml'
    
    saved_options = if saved_options_path.exist?
      # convert old _ separated names to - separated
      YAML.load(saved_options_path.read).map {|role_options_key, role_options|
        [
          role_options_key,
          role_options.map {|name, value|
            [QB::Options.cli_ize_name(name), value]
          }.to_h
        ]
      }.to_h.tap {|saved_options|
        QB.debug "found saved options", saved_options
      }
    else
      QB.debug "no saved options"
      {}
    end
    
    if saved_options.key? role.options_key
      role_saved_options = saved_options[role.options_key]
      
      QB.debug "found saved options for role", role_saved_options
      
      role_saved_options.each do |option_cli_name, value|
        option = options.role_options[option_cli_name]
        
        if option.value.nil?
          QB.debug "setting from saved options", option: option, value: value
          
          option.value = value
        end
      end
    end
  end # unless default_dir == false
  
  
  # Interactive Input
  # =====================================================================
  
  if options.ask?
    # Incomplete
    raise "COMING SOON!!!...?"
    QB::CLI.ask_for_options role: role, options: options
  end
  
  
  # Validation
  # =====================================================================
  # 
  # Should have already been taken care of if we used interactive input.
  # 
  
  # check that required options are present
  missing = options.role_options.values.select {|option|
    option.required? && option.value.nil?
  }
  
  unless missing.empty?
    puts "ERROR: options #{ missing.map {|o| o.cli_name } } are required."
    return 1
  end
  
  set_options = options.role_options.select {|k, o| !o.value.nil?}
  
  QB.debug "set options", set_options
  
  playbook_role = {'role' => role.name}
  
  playbook_vars = {
    'qb_dir' => dir,
    # depreciated due to mass potential for conflict
    'dir' => dir,
    'qb_cwd' => cwd,
    'qb_user_roles_dir' => QB::USER_ROLES_DIR.to_s,
  }
  
  set_options.values.each do |option|
    playbook_role[option.var_name] = option.value_data
  end
  
  play =
  {
    'hosts' => options.qb['hosts'],
    'vars' => playbook_vars,
    # 'gather_subset' => ['!all'],
    'gather_facts' => options.qb['facts'],
    'pre_tasks' => [
      {
        'qb_facts' => {
          'qb_dir' => dir,
        }
      },
    ],
    'roles' => [
      'nrser.blockinfile',
    ],
  }
  
  if role.meta['call_role']
    logger.debug "Calling role through qb/call..."
    
    play['tasks'] = [
      {
        'include_role' => {
          'name' => 'qb/call',
        },
        'vars' => {
          'role' => role.name,
          'args' => set_options.map { |option|
            [option.var_name, option.value_data]
          }.to_h,
        }
      }
    ]
    
    env = QB::Ansible::Env::Devel.new
    exe = [
      QB::Python.bin,
      (QB::Ansible::Env::Devel::ANSIBLE_HOME / 'bin' / 'ansible-playbook')
    ].join " "
    
  else
    play['roles'] << playbook_role
    env = QB::Ansible::Env.new
    exe = "ansible-playbook"
    
  end
  
  if options.qb['user']
    play['become'] = true
    play['become_user'] = options.qb['user']
  end
  
  playbook = [play]
  
  logger.debug "playbook", playbook
  
  # stick the role path in front to make sure we get **that** role
  env.roles_path.unshift role.path.expand_path.dirname
  
  cmd = QB::Ansible::Cmd::Playbook.new \
    env: env,
    playbook: playbook,
    role_options: options,
    chdir: (File.exists?('./ansible/ansible.cfg') ? './ansible' : nil),
    exe: exe
  
  # print
  # =====
  # 
  # print useful stuff for debugging / running outside of qb
  # 
  
  if options.qb['print'].include? 'options'
    puts "SET OPTIONS:\n\n#{ YAML.dump set_options }\n\n"
  end
  
  if options.qb['print'].include? 'env'
    puts "ENV:\n\n#{ YAML.dump cmd.env.to_h }\n\n"
  end
  
  if options.qb['print'].include? 'cmd'
    puts "COMMAND:\n\n#{ cmd.prepare }\n\n"
  end
  
  if options.qb['print'].include? 'playbook'
    puts "PLAYBOOK:\n\n#{ YAML.dump playbook }\n\n"
  end
  
  # stop here if we're not supposed to run
  exit 0 if !options.qb['run']
  
  # run
  # ===
  # 
  # stuff below here does stuff
  # 
  
  # save the options back
  if (
    dir &&
    # we set some options that we can save
    set_options.values.select {|o| o.save? }.length > 0 &&
    # the role says to save options
    role.save_options
  )
    saved_options[role.options_key] = set_options.select{|key, option|
      option.save?
    }.map {|key, option|
      [key, option.value]
    }.to_h
    
    unless saved_options_path.dirname.exist?
      FileUtils.mkdir_p saved_options_path.dirname
    end
    
    saved_options_path.open('w') do |f|
      f.write YAML.dump(saved_options)
    end
  end
  
  logger.debug "Command prepared, running...",
    command: cmd,
    prepared: cmd.prepare
  
  status = cmd.stream
  
  if status != 0
    $stderr.puts "ERROR ansible-playbook failed."
  end
  
  # exit status
  status
end

.set_debug!(args) ⇒ Object

Module (Static) Methods



52
53
54
55
56
57
# File 'lib/qb/cli.rb', line 52

def self.set_debug! args
  if DEBUG_ARGS.any? {|arg| args.include? arg}
    ENV['QB_DEBUG'] = 'true'
    DEBUG_ARGS.each {|arg| args.delete arg}
  end
end

.setup(args = []) ⇒ Fixnum

TODO:
  1. While it works, this system of finding the setup files feels kind-of wonky.
  2. Any additional entries in args after the first seem to be silently ignored. Seems like we should do something with them (run all of them?) or error.

Run a setup playbook.

The path to the setup playbook can be given as the first of args, or setup.qb.{yaml,yml} will be searched in $REPO_ROOT/dev/ and $REPO_ROOT/, where $REPO_ROOT is the Git root for the current directory.

Parameters:

  • args (Array<String>) (defaults to: [])

    Either:

    1. Empty, in which case we search for the setup playbook as detailed above.
    2. Contains a single path to the setup playbook.

Returns:

  • (Fixnum)

    The ansible-playbook command exit code.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/qb/cli/setup.rb', line 49

def self.setup args = []
  # Figure out project root and setup playbook path
  case args[0]
  when String, Pathname
    # The playbook path has been provided, use that to find the project root
    playbook_path = QB::Util.resolve args[0]
    project_root = NRSER.git_root playbook_path
    
  when nil
    # Figure the project root out from the current directory, then
    # form the playbook path from that
    project_root = NRSER.git_root '.'
    
    playbook_path = Util.find_yaml_file! \
      dirs: [
        project_root.join( 'dev' ),
        project_root,
      ],
      basename: 'setup.qb'
  
  else
    raise TypeError.new binding.erb "      First entry of `args` must be nil, String or Pathname, found:\n      \n          <%= args[0].pretty_inspect %>\n      \n      args:\n      \n          <%= args.pretty_inspect %>\n      \n    END\n  end\n  \n  unless playbook_path.file?\n    raise \"Can't find QB setup playbook at `\#{ playbook_path.to_s }`\"\n  end\n  \n  cmd = QB::Ansible::Cmd::Playbook.new \\\n    chdir: project_root,\n    extra_vars: {\n      project_root: project_root,\n      qb_dir: project_root,\n      qb_cwd: Pathname.getwd,\n      qb_user_roles_dir: QB::USER_ROLES_DIR,\n    },\n    playbook_path: playbook_path\n  \n  puts cmd.prepare\n  \n  status = cmd.stream\n  \n  if status != 0\n    $stderr.puts \"ERROR QB setup failed.\"\n  end\n  \n  exit status\n  \nend\n"

.terminal_widthObject

This code was copied from Rake, available under MIT-LICENSE Copyright (c) 2003, 2004 Jim Weirich



159
160
161
162
163
164
165
166
167
168
# File 'lib/qb/cli.rb', line 159

def self.terminal_width
  result = if ENV["QB_COLUMNS"]
    ENV["QB_COLUMNS"].to_i
  else
    unix? ? dynamic_width : DEFAULT_TERMINAL_WIDTH
  end
  result < 10 ? DEFAULT_TERMINAL_WIDTH : result
rescue
  DEFAULT_TERMINAL_WIDTH
end

.unix?Boolean

Returns:

  • (Boolean)


187
188
189
190
# File 'lib/qb/cli.rb', line 187

def self.unix?
  RUBY_PLATFORM =~ \
    /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris|irix|hpux)/i
end