Module: QB::CLI

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

Overview

Definitions

Constant Summary collapse

DEBUG_ARGS =

CLI args that common to all commands that enable debug output

['-D', '--DEBUG'].freeze
DEFAULT_TERMINAL_WIDTH =

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

80

Class Method Summary collapse

Class Method Details

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



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
# File 'lib/qb/cli.rb', line 67

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



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

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



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

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

.dynamic_widthObject

Calculate the dynamic width of the terminal



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

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

.dynamic_width_sttyObject



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

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

.dynamic_width_tputObject



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

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.



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


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



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::Cmds::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.



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
319
# File 'lib/qb/cli/run.rb', line 29

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::Cmds::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



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

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

Play //dev/setup.yml



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
# File 'lib/qb/cli/setup.rb', line 37

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 = project_root / 'dev' / 'setup.qb.yml'
  
  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::Cmds::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



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

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



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

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