# frozen_string_literal: true require 'pathname' require 'bolt/config' require 'bolt/validator' require 'bolt/pal' require 'bolt/module' module Bolt class Project BOLTDIR_NAME = 'Boltdir' CONFIG_NAME = 'bolt-project.yaml' attr_reader :path, :data, :inventory_file, :hiera_config, :puppetfile, :rerunfile, :type, :resource_types, :project_file, :downloads, :plans_path, :modulepath, :managed_moduledir, :backup_dir, :plugin_cache_file, :plan_cache_file def self.default_project create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user') # If homedir isn't defined use the system config path rescue ArgumentError create_project(Bolt::Config.system_path, 'system') end # Search recursively up the directory hierarchy for the Project. Look for a # directory called Boltdir or a file called bolt-project.yaml (for a control # repo type Project). Otherwise, repeat the check on each directory up the # hierarchy, falling back to the default if we reach the root. def self.find_boltdir(dir) dir = Pathname.new(dir) if (dir + BOLTDIR_NAME).directory? create_project(dir + BOLTDIR_NAME, 'embedded') elsif (dir + CONFIG_NAME).file? create_project(dir, 'local') elsif dir.root? default_project else Bolt::Logger.debug( "Did not detect Boltdir or bolt-project.yaml at '#{dir}'. This directory won't be loaded as a project." ) find_boltdir(dir.parent) end end def self.create_project(path, type = 'option') fullpath = Pathname.new(path).expand_path if type == 'user' begin # This is already expanded if the type is user FileUtils.mkdir_p(path) rescue StandardError Bolt::Logger.warn( "non_writeable_project", "Could not create default project at #{path}. Continuing without a writeable project. "\ "Log and rerun files will not be written." ) end end if type == 'option' && !File.directory?(path) raise Bolt::Error.new("Could not find project at #{path}", "bolt/project-error") end if !Bolt::Util.windows? && type != 'environment' && fullpath.world_writable? raise Bolt::Error.new( "Project directory '#{fullpath}' is world-writable which poses a security risk. Set "\ "BOLT_PROJECT='#{fullpath}' to force the use of this project directory.", "bolt/world-writable-error" ) end project_file = File.join(fullpath, CONFIG_NAME) data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project') default = type =~ /user|system/ ? 'default ' : '' if File.exist?(File.expand_path(project_file)) Bolt::Logger.info("Loaded #{default}project from '#{fullpath}'") end Bolt::Validator.new.tap do |validator| validator.validate(data, schema, project_file) validator.warnings.each { |warning| Bolt::Logger.warn(warning[:id], warning[:msg]) } validator.deprecations.each { |dep| Bolt::Logger.deprecate(dep[:id], dep[:msg]) } end new(data, path, type) end # Builds the schema for bolt-project.yaml used by the validator. # def self.schema { type: Hash, properties: Bolt::Config::PROJECT_OPTIONS.map { |opt| [opt, _ref: opt] }.to_h, definitions: Bolt::Config::OPTIONS } end def initialize(data, path, type = 'option') @type = type @path = Pathname.new(path).expand_path @project_file = @path + CONFIG_NAME @inventory_file = @path + 'inventory.yaml' @hiera_config = @path + 'hiera.yaml' @puppetfile = @path + 'Puppetfile' @rerunfile = @path + '.rerun.json' @resource_types = @path + '.resource_types' @downloads = @path + 'downloads' @plans_path = @path + 'plans' @managed_moduledir = @path + '.modules' @backup_dir = @path + '.bolt-bak' @plugin_cache_file = @path + '.plugin_cache.json' @plan_cache_file = @path + '.plan_cache.json' @modulepath = [(@path + 'modules').to_s] if (tc = Bolt::Config::INVENTORY_OPTIONS.keys & data.keys).any? Bolt::Logger.warn( "project_transport_config", "Transport configuration isn't supported in bolt-project.yaml. Ignoring keys #{tc}." ) end @data = data.slice(*Bolt::Config::PROJECT_OPTIONS) validate if project_file? end def to_s @path.to_s end # This API is used to prepend the project as a module to Puppet's internal # module_references list. CHANGE AT YOUR OWN RISK def to_h { path: @path.to_s, name: name, load_as_module?: load_as_module? } end def eql?(other) path == other.path end alias == eql? def project_file? @project_file.file? end def load_as_module? !name.nil? end def name @data['name'] end def tasks @data['tasks'] end def plans @data['plans'] end def plugin_cache @data['plugin-cache'] end def module_install @data['module-install'] end def disable_warnings @data['disable-warnings'] || [] end def modules mod_data = @data['modules'] || [] @modules ||= mod_data.map do |mod| if mod.is_a?(String) { 'name' => mod } else mod end end end def validate if name if name !~ Bolt::Module::MODULE_NAME_REGEX raise Bolt::ValidationError, <<~ERROR_STRING Invalid project name '#{name}' in bolt-project.yaml; project name must begin with a lowercase letter and can include lowercase letters, numbers, and underscores. ERROR_STRING elsif Dir.children(Bolt::Config::Modulepath::BOLTLIB_PATH).include?(name) raise Bolt::ValidationError, "The project '#{name}' will not be loaded. The project name conflicts "\ "with a built-in Bolt module of the same name." end elsif name.nil? && (File.directory?(plans_path) || File.directory?(@path + 'tasks') || File.directory?(@path + 'files')) message = "No project name is specified in bolt-project.yaml. Project-level content will not be available." Bolt::Logger.warn("missing_project_name", message) end end def check_deprecated_file if (@path + 'project.yaml').file? msg = "Project configuration file 'project.yaml' is deprecated; use 'bolt-project.yaml' instead." Bolt::Logger.warn("project_yaml", msg) end end end end