Module: Proxy::OpenBolt
- Extended by:
- Log, Util
- Defined in:
- lib/smart_proxy_openbolt/version.rb,
lib/smart_proxy_openbolt/executor.rb,
lib/smart_proxy_openbolt/task_job.rb,
lib/smart_proxy_openbolt/lru_cache.rb,
lib/smart_proxy_openbolt/result.rb,
lib/smart_proxy_openbolt/plugin.rb,
lib/smart_proxy_openbolt/error.rb,
lib/smart_proxy_openbolt/main.rb,
lib/smart_proxy_openbolt/job.rb,
lib/smart_proxy_openbolt/api.rb
Defined Under Namespace
Classes: Api, CliError, Error, Executor, Job, LogPathValidator, LruCache, Plugin, Result, TaskJob
Constant Summary collapse
- VERSION =
'1.2.0'.freeze
- TRANSPORTS =
['ssh', 'winrm', 'choria'].freeze
- OPENBOLT_OPTIONS =
The key should be exactly the flag name passed to OpenBolt Type must be :boolean, :string, or an array of acceptable string values Transport must be an array of transport types it applies to. This is
used to filter the openbolt options in the UI to only those relevantDefaults set here are in case the UI does not send any information for
the key, and should only be present if this value is requiredSensitive should be set to true in order to redact the value from logs
{ 'transport' => { :type => TRANSPORTS, :transport => TRANSPORTS, :default => 'ssh', :sensitive => false, :description => 'The transport method to use for connecting to target hosts.', }, 'log-level' => { :type => ['error', 'warning', 'info', 'debug', 'trace'], :transport => ['ssh', 'winrm', 'choria'], :sensitive => false, :description => 'Set the log level during OpenBolt execution.', }, 'verbose' => { :type => :boolean, :transport => ['ssh', 'winrm', 'choria'], :sensitive => false, :description => 'Run the OpenBolt command with the --verbose flag. This prints additional information during OpenBolt execution and will print any out::verbose plan statements.', }, 'noop' => { :type => :boolean, :transport => ['ssh', 'winrm', 'choria'], :sensitive => false, :description => 'Run the OpenBolt command with the --noop flag, which will make no changes to the target host.', }, 'tmpdir' => { :type => :string, :transport => ['ssh', 'winrm', 'choria'], :sensitive => false, :description => 'Directory to use for temporary files on target hosts during OpenBolt execution.', }, 'user' => { :type => :string, :transport => ['ssh', 'winrm'], :sensitive => false, :description => 'Username used for SSH or WinRM authentication.', }, 'password' => { :type => :string, :transport => ['ssh', 'winrm'], :sensitive => true, :description => 'Password used for SSH or WinRM authentication.', }, 'host-key-check' => { :type => :boolean, :transport => ['ssh'], :sensitive => false, :description => 'When enabled, perform host key verification when connecting to targets over SSH.', }, 'private-key' => { :type => :string, :transport => ['ssh'], :sensitive => false, :description => 'Path on the smart proxy host to the private key used for SSH authentication. This key must be readable by the foreman-proxy user.', }, 'run-as' => { :type => :string, :transport => ['ssh'], :sensitive => false, :description => 'The user to run commands as on the target host. This requires that the user specified in the "user" option has permission to run commands as this user.', }, 'sudo-password' => { :type => :string, :transport => ['ssh'], :sensitive => true, :description => 'Password used for privilege escalation when using SSH.', }, 'ssl' => { :type => :boolean, :transport => ['winrm'], :sensitive => false, :description => 'Use SSL when connecting to hosts via WinRM.', }, 'ssl-verify' => { :type => :boolean, :transport => ['winrm'], :sensitive => false, :description => 'Verify remote host SSL certificate when connecting to hosts via WinRM.', }, 'choria-task-agent' => { :type => ['bolt_tasks', 'shell'], :transport => ['choria'], :sensitive => false, :description => 'Choria agent used to execute tasks on target nodes. "bolt_tasks" runs tasks via the Choria bolt_tasks agent and "shell" runs them via the shell agent. Defaults to "bolt_tasks" when not specified.', }, 'choria-config-file' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Path on the smart proxy host to the Choria client configuration file. This file must be readable by the foreman-proxy user. When blank, the proxy uses a built-in default.', }, 'choria-mcollective-certname' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Override the MCollective certname for Choria client identity. When blank, the proxy derives this automatically from the SSL certificate.', }, 'choria-ssl-ca' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Path on the smart proxy host to the CA certificate used to verify Choria brokers and peers. This file must be readable by the foreman-proxy user. Must be provided together with choria-ssl-cert and choria-ssl-key.', }, 'choria-ssl-cert' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Path on the smart proxy host to the client SSL certificate used to authenticate with Choria. This file must be readable by the foreman-proxy user. Must be provided together with choria-ssl-ca and choria-ssl-key.', }, 'choria-ssl-key' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Path on the smart proxy host to the client SSL private key used to authenticate with Choria. This key must be readable by the foreman-proxy user. Must be provided together with choria-ssl-ca and choria-ssl-cert.', }, 'choria-collective' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Choria collective to route messages through.', }, 'choria-puppet-environment' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Puppet environment reported to the Choria agent when executing tasks. Defaults to "production" when not specified. Typically matches the proxy\'s environment_path setting.', }, 'choria-rpc-timeout' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Timeout in seconds for individual Choria RPC calls. Defaults to 30 when not specified.', }, 'choria-task-timeout' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Timeout in seconds for a Choria task to complete on a target node. Defaults to 300 when not specified.', }, 'choria-command-timeout' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Timeout in seconds for a Choria shell command to complete on a target node. Defaults to 60 when not specified.', }, 'choria-brokers' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Comma-separated list of Choria broker addresses in host or host:port format (e.g. broker1:4222,broker2:4222). Port defaults to 4222 if omitted.', }, 'choria-broker-timeout' => { :type => :string, :transport => ['choria'], :sensitive => false, :description => 'Timeout in seconds for establishing a connection to a Choria broker.', }, }.freeze
- SORTED_OPTIONS =
OPENBOLT_OPTIONS.sort.to_h.freeze
Class Method Summary collapse
-
.delete_artifacts(id) ⇒ Object
DELETE /job/:id/artifacts.
- .executor ⇒ Object
-
.get_result(id) ⇒ Object
/job/:id/result.
-
.get_status(id) ⇒ Object
/job/:id/status.
-
.launch_task(data) ⇒ Object
/launch/task.
-
.normalize_values(hash) ⇒ Object
Normalize options and parameters, since the UI may send unspecified options as empty strings.
-
.openbolt(command) ⇒ Object
Anything that needs to run an OpenBolt CLI command should use this.
-
.openbolt_json(command) ⇒ Object
Runs an openbolt command that is expected to produce JSON on stdout.
- .openbolt_options ⇒ Object
- .reload_tasks ⇒ Object
- .result_file_path(id) ⇒ Object
-
.scrub(options, text) ⇒ Object
Used only for display text that may contain sensitive OpenBolt options values.
-
.tasks(reload: false) ⇒ Object
/tasks or /tasks/reload.
- .validate_job_id!(id) ⇒ Object
Class Method Details
.delete_artifacts(id) ⇒ Object
DELETE /job/:id/artifacts
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 |
# File 'lib/smart_proxy_openbolt/main.rb', line 438 def delete_artifacts(id) validate_job_id!(id) file_path = result_file_path(id) real_path = File.realpath(file_path) expected_dir = File.realpath(Plugin.settings.log_dir) raise Error.new(message: 'Invalid file path') unless real_path.start_with?(expected_dir) File.delete(file_path) executor.remove_job(id) logger.info("Deleted artifacts for job #{id}") { status: 'deleted', job_id: id }.to_json rescue Errno::ENOENT logger.warning("Artifacts not found for job #{id}") { status: 'not_found', job_id: id }.to_json end |
.executor ⇒ Object
187 188 189 |
# File 'lib/smart_proxy_openbolt/main.rb', line 187 def executor @executor ||= Executor.instance end |
.get_result(id) ⇒ Object
/job/:id/result
427 428 429 430 431 432 433 434 435 |
# File 'lib/smart_proxy_openbolt/main.rb', line 427 def get_result(id) validate_job_id!(id) result = executor.result(id) return result if result.is_a?(String) raise Error.new(message: "Job not found: #{id}") if result == :invalid result.to_json rescue Errno::ENOENT raise Error.new(message: "Result file not found for job: #{id}") end |
.get_status(id) ⇒ Object
/job/:id/status
421 422 423 424 |
# File 'lib/smart_proxy_openbolt/main.rb', line 421 def get_status(id) validate_job_id!(id) { status: executor.status(id) }.to_json end |
.launch_task(data) ⇒ Object
/launch/task
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 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 |
# File 'lib/smart_proxy_openbolt/main.rb', line 264 def launch_task(data) ### Validation ### unless data.is_a?(Hash) raise Error.new(message: 'Data passed in to launch_task function is not a hash. This is most likely a bug in the smart_proxy_openbolt plugin. Please file an issue with the maintainers.') end fields = ['name', 'parameters', 'targets', 'options'] unless fields.all? { |k| data.key?(k) } raise Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'options'.") end name = data['name'] params = data['parameters'] || {} targets = data['targets'] = data['options'] || {} logger.info("Task: #{name}") logger.info("Parameters: #{params.inspect}") logger.info("Targets: #{targets.inspect}") logger.info("Options: #{scrub(, .inspect)}") # Validate name raise Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty? raise Error.new(message: "Task #{name} not found.") unless tasks.key?(name) # Validate parameters raise Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash) extra = params.keys - tasks[name]['parameters'].keys raise Error.new(message: "Unknown parameters: #{extra}") unless extra.empty? # Normalize parameters, ensuring blank values are not passed params = normalize_values(params) logger.info("Normalized parameters: #{params.inspect}") # Check required parameters after normalization so blank values are caught missing = [] tasks[name]['parameters'].each do |k, v| next if v['type']&.start_with?('Optional[') next if v.key?('default') missing << k unless params.key?(k) end raise Error.new(message: "Missing required parameters: #{missing}") unless missing.empty? # Validate targets raise Error.new(message: "The 'targets' value should be a string or an array.") unless targets.is_a?(String) || targets.is_a?(Array) if targets.is_a?(Array) raise Error.new(message: "All target values must be strings.") unless targets.all?(String) targets = targets.map(&:strip).reject(&:empty?) else targets = targets.split(',').map(&:strip).reject(&:empty?) end raise Error.new(message: "The 'targets' value should not be empty.") if targets.empty? # Validate options raise Error.new(message: "The 'options' value should be a hash.") unless .is_a?(Hash) unknown = .keys - OPENBOLT_OPTIONS.keys raise Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty? # Normalize options, removing blank values = normalize_values() logger.info("Normalized options: #{scrub(, .inspect)}") OPENBOLT_OPTIONS.each { |key, | [key] ||= [:default] if .key?(:default) } logger.info("Options with required defaults: #{scrub(, .inspect)}") # Choria transport defaults: fill in config file, SSL certs, and # certname when the user has not provided them. if ['transport'] == 'choria' user_provided_config = .key?('choria-config-file') unless user_provided_config shipped_config = File.join(File.dirname(__FILE__), 'config', 'choria-client.conf') if File.readable?(shipped_config) ['choria-config-file'] = shipped_config else logger.warn("Choria: shipped config at #{shipped_config} is not readable " \ "(exists=#{File.exist?(shipped_config)}). Check package installation " \ "and foreman-proxy user permissions.") end end if !user_provided_config missing_ssl = [] missing_ssl << 'ssl_certificate' if Proxy::SETTINGS.ssl_certificate.to_s.strip.empty? missing_ssl << 'ssl_private_key' if Proxy::SETTINGS.ssl_private_key.to_s.strip.empty? missing_ssl << 'ssl_ca_file' if Proxy::SETTINGS.ssl_ca_file.to_s.strip.empty? if missing_ssl.empty? ['choria-ssl-cert'] ||= Proxy::SETTINGS.ssl_certificate ['choria-ssl-key'] ||= Proxy::SETTINGS.ssl_private_key ['choria-ssl-ca'] ||= Proxy::SETTINGS.ssl_ca_file else logger.warn("Choria: cannot default SSL from proxy settings, missing: #{missing_ssl.join(', ')}. " \ "Set choria-ssl-cert, choria-ssl-key, and choria-ssl-ca explicitly.") end elsif !.key?('choria-ssl-cert') logger.info('Choria: custom config file provided without SSL options. ' \ 'SSL settings will be read from the config file.') end unless .key?('choria-mcollective-certname') cert_path = ['choria-ssl-cert'] if cert_path.nil? && user_provided_config logger.info('Choria: custom config file provided, certname will come from the config file or ' \ "default to '<user>.mcollective'. Set 'choria-mcollective-certname' if needed.") elsif cert_path.nil? logger.warn('Choria: no choria-ssl-cert available, cannot derive mcollective-certname.') elsif !File.readable?(cert_path) logger.warn("Choria: cannot derive mcollective-certname, cert at #{cert_path} is not readable. " \ "Set 'choria-mcollective-certname' explicitly or fix file permissions.") else begin cert = OpenSSL::X509::Certificate.new(File.read(cert_path)) cn = cert.subject.to_a.find { |name, _, _| name == 'CN' } if cn ['choria-mcollective-certname'] = cn[1] else logger.warn("Choria: certificate at #{cert_path} has no CN. " \ "Set 'choria-mcollective-certname' explicitly.") end rescue OpenSSL::X509::CertificateError => e raise Error.new( message: "Cannot read Choria certificate at #{cert_path}: #{e.}. " \ "Set 'choria-mcollective-certname' explicitly or fix the certificate file." ) end end end logger.info("Choria options after defaults: #{scrub(, .inspect)}") end # Validate option types = .to_h do |key, value| type = OPENBOLT_OPTIONS[key][:type] case type when :boolean if value.is_a?(String) value = value.downcase.strip raise Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value) value = value == 'true' end raise Error.new(message: "Option #{key} must be a boolean true or false. It appears to be #{value.class}.") unless [TrueClass, FalseClass].include?(value.class) when :string raise Error.new(message: "Option #{key} must have a value when the option is specified.") if value.to_s.empty? when Array raise Error.new(message: "Option #{key} must have one of the following values: #{OPENBOLT_OPTIONS[key][:type]}") unless OPENBOLT_OPTIONS[key][:type].include?(value.to_s) end [key, value] end logger.info("Final options: #{scrub(, .inspect)}") ### Run the task ### task = TaskJob.new(name, params, , targets) id = executor.add_job(task) { id: id }.to_json end |
.normalize_values(hash) ⇒ Object
Normalize options and parameters, since the UI may send unspecified options as empty strings
249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/smart_proxy_openbolt/main.rb', line 249 def normalize_values(hash) return {} unless hash.is_a?(Hash) hash.transform_values do |value| if value.is_a?(String) value = value.strip value = nil if value.empty? elsif value.is_a?(Array) value = value.map { |v| v.is_a?(String) ? v.strip : v } value = nil if value.empty? end value end.compact end |
.openbolt(command) ⇒ Object
Anything that needs to run an OpenBolt CLI command should use this. At the moment, the full output is held in memory and passed back. If this becomes a problem, we can stream to disk and point to it.
For task runs, the log goes to stderr and the result to stdout when –format json is specified. At some point, figure out how to make OpenBolt’s logger log to a file instead without having to have a special project config file. Returns [stdout, stderr, exitcode]. Handles the case where the process is killed by a signal (exitstatus is nil).
489 490 491 492 493 494 495 496 497 498 499 |
# File 'lib/smart_proxy_openbolt/main.rb', line 489 def openbolt(command) env = { 'BOLT_GEM' => 'true', 'BOLT_DISABLE_ANALYTICS' => 'true' } stdout, stderr, status = Open3.capture3(env, *command) exitcode = status.exitstatus if exitcode.nil? # 128 + signal follows the Unix/shell convention for signal exit codes. exitcode = 128 + (status.termsig || 0) stderr = "Process was killed by signal #{status.termsig}.\n#{stderr}" end [stdout, stderr, exitcode] end |
.openbolt_json(command) ⇒ Object
Runs an openbolt command that is expected to produce JSON on stdout. Returns the parsed JSON hash. Raises CliError on non-zero exit or Error on JSON parse failure.
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/smart_proxy_openbolt/main.rb', line 458 def openbolt_json(command) stdout, stderr, exitcode = openbolt(command) unless exitcode.zero? raise CliError.new( message: "Error running '#{command.first(4).join(' ')}'.", exitcode: exitcode, stdout: stdout, stderr: stderr, command: command.join(' ') ) end begin JSON.parse(stdout) rescue JSON::ParserError => e raise Error.new( message: "Error parsing JSON output from '#{command.first(4).join(' ')}'.", exception: e ) end end |
.openbolt_options ⇒ Object
183 184 185 |
# File 'lib/smart_proxy_openbolt/main.rb', line 183 def SORTED_OPTIONS end |
.reload_tasks ⇒ Object
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 |
# File 'lib/smart_proxy_openbolt/main.rb', line 211 def reload_tasks task_data = {} # Get a list of all tasks command = ['bolt', 'task', 'show', '--project', Plugin.settings.environment_path, '--format', 'json'] parsed = openbolt_json(command) task_list = parsed['tasks'] unless task_list.is_a?(Array) raise Error.new( message: "Unexpected output from 'bolt task show': expected 'tasks' to be an array, got #{task_list.class}." ) end # Get metadata for each task task_list.each do |task_entry| name = task_entry[0] command = ['bolt', 'task', 'show', name, '--project', Plugin.settings.environment_path, '--format', 'json'] result = openbolt_json(command) = result['metadata'] if .nil? raise Error.new( message: "Invalid metadata found for task #{name}" ) end task_data[name] = { 'description' => ['description'] || '', 'parameters' => ['parameters'] || {}, } end @tasks = task_data end |
.result_file_path(id) ⇒ Object
196 197 198 |
# File 'lib/smart_proxy_openbolt/main.rb', line 196 def result_file_path(id) File.join(Plugin.settings.log_dir, "#{id}.json") end |
.scrub(options, text) ⇒ Object
Used only for display text that may contain sensitive OpenBolt options values. Should not be used to pass anything to the CLI.
503 504 505 506 507 508 509 510 511 |
# File 'lib/smart_proxy_openbolt/main.rb', line 503 def scrub(, text) sensitive = .select { |key, _| OPENBOLT_OPTIONS[key] && OPENBOLT_OPTIONS[key][:sensitive] } sensitive.each_value do |value| redact = value.to_s next if redact.empty? text = text.gsub(redact, '*****') end text end |
.tasks(reload: false) ⇒ Object
/tasks or /tasks/reload
201 202 203 204 205 206 207 208 209 |
# File 'lib/smart_proxy_openbolt/main.rb', line 201 def tasks(reload: false) # If we need to reload, only one instance of the reload # should happen at once. Make others wait until it is # finished. @mutex.synchronize do @tasks = nil if reload @tasks || reload_tasks end end |