# frozen_string_literal: true require 'bolt/task/run' module Bolt class Plugin class Module class InvalidPluginData < Bolt::Plugin::PluginError def initialize(plugin, msg) msg = "Invalid Plugin Data for #{plugin}: #{msg}" super(msg, 'bolt/invalid-plugin-data') end end def self.load(name, modules, opts) mod = modules[name] if mod&.plugin? opts[:mod] = mod plugin = Bolt::Plugin::Module.new(opts) plugin.setup plugin else raise PluginError::Unknown, name end end attr_reader :config def initialize(mod:, context:, config:, **_opts) @module = mod @config = config @context = context end # This method interacts with the module on disk so it's separate from initialize def setup @data = load_data @config_schema = process_schema(@data['config'] || {}) @hook_map = find_hooks(@data['hooks'] || {}) validate_config(@config, @config_schema) end def name @module.name end def hooks (@hook_map.keys + [:validate_resolve_reference]).uniq end def config? @data.include?('config') && !@data['config'].empty? end def load_data JSON.parse(File.read(@module.plugin_data_file)) rescue JSON::ParserError => e raise InvalidPluginData.new(e.message, name) end def process_schema(schema) raise InvalidPluginData.new('config specification is not an object', name) unless schema.is_a?(Hash) schema.each do |key, val| unless key =~ /\A[a-z][a-z0-9_]*\z/ raise InvalidPluginData.new("config specification key, '#{key}', is not allowed", name) end unless val.is_a?(Hash) && (val['type'] || '').is_a?(String) raise InvalidPluginData.new("config specification #{val.to_json} is not allowed", name) end type_string = val['type'] || 'Any' begin val['pcore_type'] = Puppet::Pops::Types::TypeParser.singleton.parse(type_string) if val['pcore_type'].is_a? Puppet::Pops::Types::PTypeReferenceType raise InvalidPluginData.new("Could not find type '#{type_string}' for #{key}", name) end rescue Puppet::ParseError raise InvalidPluginData.new("Could not parse type '#{type_string}' for #{key}", name) end end schema end def validate_config(config, config_schema) config.keys.each do |key| msg = "Config for #{name} plugin contains unexpected key #{key}" raise Bolt::ValidationError, msg unless config_schema.include?(key) end config_schema.each do |key, spec| val = config[key] unless spec['pcore_type'].instance?(val) raise Bolt::ValidationError, "#{name} plugin expects a #{spec['type']} for key #{key}, got: #{val}" end val.nil? end nil end def find_hooks(hook_data) raise InvalidPluginData.new("'hooks' must be a hash", name) unless hook_data.is_a?(Hash) hooks = {} # Load hooks specified in the config hook_data.each do |hook_name, hook_spec| unless hook_spec.is_a?(Hash) && hook_spec['task'].is_a?(String) msg = "Unexpected hook specification #{hook_spec.to_json} in #{@name} for hook #{hook_name}" raise InvalidPluginData.new(msg, name) end begin task = @context.get_validated_task(hook_spec['task']) rescue Bolt::Error => e msg = if e.kind == 'bolt/unknown-task' "Plugin #{name} specified an unkown task '#{hook_spec['task']}' for a hook" else "Plugin #{name} could not load task '#{hook_spec['task']}': #{e.message}" end raise InvalidPluginData.new(msg, name) end hooks[hook_name.to_sym] = { 'task' => task } end # Check for tasks for any hooks not already defined (Set.new(KNOWN_HOOKS.map) - hooks.keys).each do |hook_name| task_name = "#{name}::#{hook_name}" begin task = @context.get_validated_task(task_name) rescue Bolt::Error => e raise e unless e.kind == 'bolt/unknown-task' end hooks[hook_name] = { 'task' => task } if task end Bolt::Util.symbolize_top_level_keys(hooks) end def validate_params(task, params) @context.validate_params(task.name, params) end def process_params(task, opts) # opts are passed directly from inventory but all of the _ options are # handled previously. That may not always be the case so filter them # out now. _meta, params = opts.partition { |key, _val| key.start_with?('_') }.map(&:to_h) metaparams = {} metaparams['_config'] = config if config? metaparams['_boltdir'] = @context.boltdir validate_params(task, params) [params, metaparams] end def run_task(task, opts) params, metaparams = process_params(task, opts) params = params.merge(metaparams) # There are no executor options to pass now. options = { "_catch_errors" => true } result = @context.run_local_task(task, params, options).first raise Bolt::Error.new(result.error_hash['msg'], result.error_hash['kind']) unless result.ok result.value end def run_hook(hook_name, opts, value = true) hook = @hook_map[hook_name] # This shouldn't happen if the Plugin api is used raise PluginError::UnsupportedHook.new(name, hook_name) unless hook result = run_task(hook['task'], opts) if value unless result.include?('value') msg = "Plugin #{name} result did not include a value, got #{result}" raise Bolt::Plugin::PluginError::ExecutionError.new(msg, name, hook_name) end result['value'] end end def validate_resolve_reference(opts) params = opts.reject { |k, _v| k.start_with?('_') } sig = @hook_map[:resolve_reference]['task'] if sig validate_params(sig, params) end if @hook_map.include?(:validate_resolve_reference) run_hook(:validate_resolve_reference, opts, false) end end # These are all the same but are defined explicitly for clarity def resolve_reference(opts) run_hook(__method__, opts) end def secret_encrypt(opts) run_hook(__method__, opts) end def secret_decrypt(opts) run_hook(__method__, opts) end def secret_createkeys(opts = {}) run_hook(__method__, opts) end def puppet_library(opts, target, apply_prep) tasksig = @hook_map[:puppet_library] # this also validates params, meta_params = process_params(tasksig, opts) # our metaparams are meant for the task not the executor params = params.merge(meta_params) task = Bolt::Task.new(tasksig) proc do apply_prep.run_task([target], task, params).first end end end end end