bolt 2.26.0 → 2.27.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 195c9ad94788e4811c9a90accb11515fc707346106827e15ef71995b016efbcc
4
- data.tar.gz: 7259f2a7b9af58dab4b4f8224360fe8f1570e042c43b6becb183a0dd1257d91e
3
+ metadata.gz: 851cd71cf3bbdd91a522f123db238205ca21875c7d1c9bf1eefe4850399ffb5d
4
+ data.tar.gz: 4ec2ca689f05d37ccbc1cd3962edae53dfcda79c0a58facd9a2f228af5df914c
5
5
  SHA512:
6
- metadata.gz: 2092172d760e7a806e865a3a1315ec2687cff1835b8b44addec9de208711a16be2193ff570a9cae65a620b6d9203575ccb64c2798c71509dd2fc1a0d41ca3399
7
- data.tar.gz: 77828dd923626abe08c0bbf8ef0f30175462aab55a8584127359dde412f37b17d7cd952a4bd1be2ce115f47db4f8d6f4269eb455f063ff01ee1297a4637c03c8
6
+ metadata.gz: 3d595d36833a70860c7c997fff4cc55ce682dd3b3b9c4fc5e7b36067cbb73a665e65d473097993963f8409eb5a06a25b5b834de9dff6f42ad8e244972ab0ac4e
7
+ data.tar.gz: d136cd117711e3a103480b7cae52661e8ad3beef5023552f3bbaa8235e8f795410e13a1955290c7313fb6f7fced1485b275b8430a0d5c81297e11e1ff911f458
@@ -93,6 +93,10 @@ module Bolt
93
93
  def self.write_config(filename, config)
94
94
  FileUtils.mkdir_p(File.dirname(filename))
95
95
  File.write(filename, config.to_yaml)
96
+ rescue StandardError => e
97
+ Bolt::Logger.warn_once('unwriteable_file', "Could not write analytics configuration to #{filename}.")
98
+ # This will get caught by build_client and create a NoopClient
99
+ raise e
96
100
  end
97
101
 
98
102
  class Client
@@ -75,23 +75,24 @@ module Bolt
75
75
  end
76
76
  end
77
77
 
78
- def compile(target, catalog_input)
78
+ def compile(target, scope)
79
79
  # This simplified Puppet node object is what .local uses to determine the
80
80
  # certname of the target
81
81
  node = Puppet::Node.from_data_hash('name' => target.name,
82
82
  'parameters' => { 'clientcert' => target.name })
83
83
  trusted = Puppet::Context::TrustedInformation.local(node)
84
- catalog_input[:target] = {
84
+ target_data = {
85
85
  name: target.name,
86
86
  facts: @inventory.facts(target).merge('bolt' => true),
87
87
  variables: @inventory.vars(target),
88
88
  trusted: trusted.to_h
89
89
  }
90
+ catalog_request = scope.merge(target: target_data)
90
91
 
91
92
  bolt_catalog_exe = File.join(libexec, 'bolt_catalog')
92
93
  old_path = ENV['PATH']
93
94
  ENV['PATH'] = "#{RbConfig::CONFIG['bindir']}#{File::PATH_SEPARATOR}#{old_path}"
94
- out, err, stat = Open3.capture3('ruby', bolt_catalog_exe, 'compile', stdin_data: catalog_input.to_json)
95
+ out, err, stat = Open3.capture3('ruby', bolt_catalog_exe, 'compile', stdin_data: catalog_request.to_json)
95
96
  ENV['PATH'] = old_path
96
97
 
97
98
  # If bolt_catalog does not return valid JSON, we should print stderr to
@@ -192,7 +193,7 @@ module Bolt
192
193
  plan_vars: plan_vars,
193
194
  # This data isn't available on the target config hash
194
195
  config: @inventory.transport_data_get
195
- }
196
+ }.freeze
196
197
  description = options[:description] || 'apply catalog'
197
198
 
198
199
  required_modules = options[:required_modules].nil? ? nil : Array(options[:required_modules])
@@ -64,6 +64,15 @@ module Bolt
64
64
  when 'guide'
65
65
  { flags: OPTIONS[:global] + %w[format],
66
66
  banner: GUIDE_HELP }
67
+ when 'module'
68
+ case action
69
+ when 'install'
70
+ { flags: OPTIONS[:global] + %w[configfile force project],
71
+ banner: MODULE_INSTALL_HELP }
72
+ else
73
+ { flags: OPTIONS[:global],
74
+ banner: MODULE_HELP }
75
+ end
67
76
  when 'plan'
68
77
  case action
69
78
  when 'convert'
@@ -341,6 +350,35 @@ module Bolt
341
350
  Show the list of targets an action would run on.
342
351
  HELP
343
352
 
353
+ MODULE_HELP = <<~HELP
354
+ NAME
355
+ module
356
+
357
+ USAGE
358
+ bolt module <action> [options]
359
+
360
+ DESCRIPTION
361
+ Install the project's modules
362
+
363
+ ACTIONS
364
+ install Install the project's modules
365
+ HELP
366
+
367
+ MODULE_INSTALL_HELP = <<~HELP
368
+ NAME
369
+ install
370
+
371
+ USAGE
372
+ bolt module install [options]
373
+
374
+ DESCRIPTION
375
+ Install the project's modules.
376
+
377
+ Module declarations are loaded from the project's configuration
378
+ file. Bolt will automatically resolve all module dependencies,
379
+ generate a Puppetfile, and install the modules.
380
+ HELP
381
+
344
382
  PLAN_HELP = <<~HELP
345
383
  NAME
346
384
  plan
@@ -828,7 +866,7 @@ module Bolt
828
866
  "This option is experimental.") do |exec|
829
867
  @options[:'copy-command'] = exec
830
868
  end
831
- define('--connect-timeout TIMEOUT', Integer, 'Connection timeout (defaults vary)') do |timeout|
869
+ define('--connect-timeout TIMEOUT', Integer, 'Connection timeout in seconds (defaults vary)') do |timeout|
832
870
  @options[:'connect-timeout'] = timeout
833
871
  end
834
872
  define('--[no-]tty', 'Request a pseudo TTY on targets that support it') do |tty|
@@ -864,9 +902,9 @@ module Bolt
864
902
  define('--modules MODULES',
865
903
  'A comma-separated list of modules to install from the Puppet Forge',
866
904
  'when initializing a project. Resolves and installs all dependencies.') do |modules|
867
- @options[:modules] = modules.split(',')
905
+ @options[:modules] = modules.split(',').map { |mod| { 'name' => mod } }
868
906
  end
869
- define('--force', 'Overwrite existing key pairs') do |_force|
907
+ define('--force', 'Force a destructive action') do |_force|
870
908
  @options[:force] = true
871
909
  end
872
910
 
@@ -40,7 +40,8 @@ module Bolt
40
40
  'group' => %w[show],
41
41
  'project' => %w[init migrate],
42
42
  'apply' => %w[],
43
- 'guide' => %w[] }.freeze
43
+ 'guide' => %w[],
44
+ 'module' => %w[install] }.freeze
44
45
 
45
46
  attr_reader :config, :options
46
47
 
@@ -210,10 +211,14 @@ module Bolt
210
211
  end
211
212
 
212
213
  def validate(options)
213
- unless COMMANDS.include?(options[:subcommand])
214
+ # Disables the 'module' subcommand unless the module feature flag is set.
215
+ commands = COMMANDS.dup
216
+ commands.delete('module') unless ENV['BOLT_MODULE_FEATURE']
217
+
218
+ unless commands.include?(options[:subcommand])
214
219
  raise Bolt::CLIError,
215
220
  "Expected subcommand '#{options[:subcommand]}' to be one of " \
216
- "#{COMMANDS.keys.join(', ')}"
221
+ "#{commands.keys.join(', ')}"
217
222
  end
218
223
 
219
224
  actions = COMMANDS[options[:subcommand]]
@@ -353,7 +358,7 @@ module Bolt
353
358
  # Initialize inventory and targets. Errors here are better to catch early.
354
359
  # options[:target_args] will contain a string/array version of the targetting options this is passed to plans
355
360
  # options[:targets] will contain a resolved set of Target objects
356
- unless %w[project puppetfile secret guide].include?(options[:subcommand]) ||
361
+ unless %w[guide module project puppetfile secret].include?(options[:subcommand]) ||
357
362
  %w[convert new show].include?(options[:action])
358
363
  update_targets(options)
359
364
  end
@@ -443,12 +448,17 @@ module Bolt
443
448
  when 'run'
444
449
  code = run_plan(options[:object], options[:task_options], options[:target_args], options)
445
450
  end
451
+ when 'module'
452
+ case options[:action]
453
+ when 'install'
454
+ code = install_project_modules
455
+ end
446
456
  when 'puppetfile'
447
457
  case options[:action]
448
458
  when 'generate-types'
449
459
  code = generate_types
450
460
  when 'install'
451
- code = install_puppetfile(config.puppetfile_config, config.puppetfile, config.modulepath)
461
+ code = install_puppetfile(config.puppetfile_config, config.puppetfile, config.modulepath.first)
452
462
  end
453
463
  when 'secret'
454
464
  code = Bolt::Secret.execute(plugins, outputter, options)
@@ -798,36 +808,38 @@ module Bolt
798
808
  old_config = project + 'bolt.yaml'
799
809
  config = project + 'bolt-project.yaml'
800
810
  puppetfile = project + 'Puppetfile'
801
- modulepath = [project + 'modules']
811
+ moduledir = project + 'modules'
812
+
813
+ # Warn the user if the project directory already exists. We don't error
814
+ # here since users might not have installed any modules yet. If both
815
+ # bolt.yaml and bolt-project.yaml exist, this will just warn about
816
+ # bolt-project.yaml and subsequent Bolt actions will warn about both files
817
+ # existing.
818
+ if config.exist?
819
+ @logger.warn "Found existing project directory at #{project}. Skipping file creation."
820
+ elsif old_config.exist?
821
+ @logger.warn "Found existing #{old_config.basename} at #{project}. "\
822
+ "#{old_config.basename} is deprecated, please rename to #{config.basename}."
823
+ end
802
824
 
803
- # If modules were specified, first check if there is already a Puppetfile at the project
804
- # directory, erroring if there is. If there is no Puppetfile, generate the Puppetfile
805
- # content by resolving the specified modules and all their dependencies.
806
- # We generate the Puppetfile first so that any errors in resolving modules and their
807
- # dependencies are caught early and do not create a project directory.
825
+ # If modules were specified, first check if there is already a Puppetfile
826
+ # at the project directory, erroring if there is. If there is no
827
+ # Puppetfile, install the specified modules. The module installer will
828
+ # resolve dependencies, generate a Puppetfile, and install the modules.
808
829
  if options[:modules]
809
830
  if puppetfile.exist?
810
831
  raise Bolt::CLIError,
811
- "Found existing Puppetfile at #{puppetfile}, unable to initialize project with "\
812
- "#{options[:modules].join(', ')}"
813
- else
814
- puppetfile_specs = resolve_puppetfile_specs
832
+ "Found existing Puppetfile at #{puppetfile}, unable to initialize "\
833
+ "project with modules."
815
834
  end
835
+
836
+ install_modules(puppetfile, {}, moduledir, options[:modules])
816
837
  end
817
838
 
818
- # Warn the user if the project directory already exists. We don't error here since users
819
- # might not have installed any modules yet.
820
- # If both bolt.yaml and bolt-project.yaml exist, this will just warn
821
- # about bolt-project.yaml and subsequent Bolt actions will warn about
822
- # both files existing
823
- if config.exist?
824
- @logger.warn "Found existing project directory at #{project}. Skipping file creation."
825
- # This won't get called if bolt-project.yaml exists
826
- elsif old_config.exist?
827
- @logger.warn "Found existing #{old_config.basename} at #{project}. "\
828
- "#{old_config.basename} is deprecated, please rename to #{config.basename}."
829
- # Bless the project directory as a...wait for it...project
830
- else
839
+ # If either bolt.yaml or bolt-project.yaml exist, the user has already
840
+ # been warned and we can just finish project creation. Otherwise, create a
841
+ # bolt-project.yaml with the project name in it.
842
+ unless config.exist? || old_config.exist?
831
843
  begin
832
844
  content = { 'name' => name }
833
845
  File.write(config.to_path, content.to_yaml)
@@ -837,109 +849,82 @@ module Bolt
837
849
  end
838
850
  end
839
851
 
840
- # Write the generated Puppetfile to the fancy new project
841
- if puppetfile_specs
842
- File.write(puppetfile, puppetfile_specs.join("\n"))
843
- outputter.print_message "Successfully created Puppetfile at #{puppetfile}"
844
- # Install the modules from our shiny new Puppetfile
845
- if install_puppetfile({}, puppetfile, modulepath)
846
- outputter.print_message "Successfully installed #{options[:modules].join(', ')}"
847
- else
848
- raise Bolt::CLIError, "Could not install #{options[:modules].join(', ')}"
849
- end
850
- end
851
-
852
852
  0
853
853
  end
854
854
 
855
- # Resolves Puppetfile specs from user-specified modules and dependencies resolved
856
- # by the puppetfile-resolver gem.
857
- def resolve_puppetfile_specs
858
- require 'puppetfile-resolver'
859
-
860
- # Build the document model from the module names, defaulting to the latest version of each module
861
- model = PuppetfileResolver::Puppetfile::Document.new('')
862
- options[:modules].each do |mod_name|
863
- model.add_module(
864
- PuppetfileResolver::Puppetfile::ForgeModule.new(mod_name).tap { |mod| mod.version = :latest }
865
- )
866
- end
867
-
868
- # Make sure the Puppetfile model is valid
869
- unless model.valid?
870
- raise Bolt::ValidationError,
871
- "Unable to resolve dependencies for #{options[:modules].join(', ')}"
855
+ # Installs modules declared in the project configuration file.
856
+ #
857
+ def install_project_modules
858
+ if config.project.modules.nil?
859
+ outputter.print_message "Project configuration file '#{config.project.project_file}' "\
860
+ "does not specify any module dependencies. Nothing to do."
861
+ return 0
872
862
  end
873
863
 
874
- # Create the resolver using the Puppetfile model. nil disables Puppet version restrictions.
875
- resolver = PuppetfileResolver::Resolver.new(model, nil)
876
-
877
- # Configure and resolve the dependency graph
878
- result = resolver.resolve(
879
- cache: nil,
880
- ui: nil,
881
- module_paths: [],
882
- allow_missing_modules: true
864
+ install_modules(
865
+ config.puppetfile,
866
+ config.puppetfile_config,
867
+ config.project.path + '.modules',
868
+ config.project.modules
883
869
  )
870
+ end
884
871
 
885
- # Validate that the modules exist
886
- missing_graph = result.specifications.select do |_name, spec|
887
- spec.instance_of? PuppetfileResolver::Models::MissingModuleSpecification
888
- end
889
-
890
- if missing_graph.any?
891
- titles = model.modules.each_with_object({}) do |mod, acc|
892
- acc[mod.name] = mod.title
872
+ # Installs modules declared in the project configuration file.
873
+ #
874
+ def install_modules(puppetfile_path, config, moduledir, modules)
875
+ require 'bolt/puppetfile'
876
+ require 'bolt/puppetfile/installer'
877
+
878
+ puppetfile = Bolt::Puppetfile.new(modules)
879
+
880
+ # If the Puppetfile exists, check if it includes specs for each declared
881
+ # module, erroring if there are any missing. Otherwise, resolve the
882
+ # module dependencies and write a new Puppetfile. Users can forcibly
883
+ # overwrite an existing Puppetfile with the '--force' option.
884
+ if puppetfile_path.exist? && !options[:force]
885
+ outputter.print_message "Parsing existing Puppetfile at #{puppetfile_path}"
886
+ existing = Bolt::Puppetfile.parse(puppetfile_path)
887
+
888
+ unless existing.modules.superset? puppetfile.modules
889
+ missing_modules = puppetfile.modules - existing.modules
890
+
891
+ raise Bolt::Error.new(
892
+ "Puppetfile #{puppetfile_path} is missing specifications for modules: "\
893
+ "#{missing_modules.map(&:title).join(', ')}. This may not be a Puppetfile "\
894
+ "managed by Bolt. To forcibly overwrite the Puppetfile, run with the "\
895
+ "'--force' option.",
896
+ 'bolt/missing-module-specs'
897
+ )
893
898
  end
894
-
895
- names = titles.values_at(*missing_graph.keys)
896
- plural = names.count == 1 ? '' : 's'
897
-
898
- raise Bolt::ValidationError,
899
- "Unknown module name#{plural} #{names.join(', ')}"
900
- end
901
-
902
- # Filter the dependency graph to only include module specifications
903
- spec_graph = result.specifications.select do |_name, spec|
904
- spec.instance_of? PuppetfileResolver::Models::ModuleSpecification
899
+ else
900
+ outputter.print_message "Resolving module dependencies, this may take a moment"
901
+ puppetfile.resolve
902
+ outputter.print_message "Writing Puppetfile at #{puppetfile_path}"
903
+ puppetfile.write(puppetfile_path, force: true)
905
904
  end
906
905
 
907
- # Map specification models to a Puppetfile specification
908
- spec_graph.values.map do |spec|
909
- "mod '#{spec.owner}-#{spec.name}', '#{spec.version}'"
910
- end
911
- end
906
+ outputter.print_message "Syncing modules from #{puppetfile_path} to #{moduledir}"
907
+ ok = Bolt::Puppetfile::Installer.new(config).install(puppetfile_path, moduledir)
912
908
 
913
- def install_puppetfile(config, puppetfile, modulepath)
914
- require 'r10k/cli'
915
- require 'bolt/r10k_log_proxy'
909
+ # Automatically generate types after installing modules.
910
+ pal.generate_types
916
911
 
917
- if puppetfile.exist?
918
- moduledir = modulepath.first.to_s
919
- r10k_opts = {
920
- root: puppetfile.dirname.to_s,
921
- puppetfile: puppetfile.to_s,
922
- moduledir: moduledir
923
- }
912
+ outputter.print_puppetfile_result(ok, puppetfile_path, moduledir)
913
+ ok ? 0 : 1
914
+ end
924
915
 
925
- settings = R10K::Settings.global_settings.evaluate(config)
926
- R10K::Initializers::GlobalInitializer.new(settings).call
927
- install_action = R10K::Action::Puppetfile::Install.new(r10k_opts, nil)
916
+ # Loads a Puppetfile and installs its modules.
917
+ #
918
+ def install_puppetfile(config, puppetfile, moduledir)
919
+ require 'bolt/puppetfile/installer'
928
920
 
929
- # Override the r10k logger with a proxy to our own logger
930
- R10K::Logging.instance_variable_set(:@outputter, Bolt::R10KLogProxy.new)
921
+ ok = Bolt::Puppetfile::Installer.new(config).install(puppetfile, moduledir)
931
922
 
932
- ok = install_action.call
933
- outputter.print_puppetfile_result(ok, puppetfile, moduledir)
934
- # Automatically generate types after installing modules
935
- pal.generate_types
923
+ # Automatically generate types after installing modules.
924
+ pal.generate_types
936
925
 
937
- ok ? 0 : 1
938
- else
939
- raise Bolt::FileError.new("Could not find a Puppetfile at #{puppetfile}", puppetfile)
940
- end
941
- rescue R10K::Error => e
942
- raise PuppetfileError, e
926
+ outputter.print_puppetfile_result(ok, puppetfile, moduledir)
927
+ ok ? 0 : 1
943
928
  end
944
929
 
945
930
  def pal
@@ -64,7 +64,7 @@ module Bolt
64
64
  end
65
65
 
66
66
  data = load_defaults(project).push(
67
- filepath: project.config_file,
67
+ filepath: configfile,
68
68
  data: conf,
69
69
  logs: logs,
70
70
  deprecations: []
@@ -344,6 +344,14 @@ module Bolt
344
344
  end
345
345
 
346
346
  private def update_logs(logs)
347
+ begin
348
+ if logs['bolt-debug.log'] && logs['bolt-debug.log'] != 'disable'
349
+ FileUtils.touch(File.expand_path('bolt-debug.log', @project.path))
350
+ end
351
+ rescue StandardError
352
+ logs.delete('bolt-debug.log')
353
+ end
354
+
347
355
  logs.each_with_object({}) do |(key, val), acc|
348
356
  # Remove any disabled logs
349
357
  next if val == 'disable'
@@ -227,6 +227,24 @@ module Bolt
227
227
  _example: ["~/.puppetlabs/bolt/modules", "~/.puppetlabs/bolt/site-modules"],
228
228
  _default: ["project/modules", "project/site-modules", "project/site"]
229
229
  },
230
+ "modules" => {
231
+ description: "A list of module dependencies for the project. Each dependency is a map of data specifying "\
232
+ "the module to install. To install the project's module dependencies, run the `bolt module "\
233
+ "install` command.",
234
+ type: Array,
235
+ items: {
236
+ type: Hash,
237
+ required: ["name"],
238
+ properties: {
239
+ "name" => {
240
+ description: "The name of the module.",
241
+ type: String
242
+ }
243
+ }
244
+ },
245
+ _plugin: false,
246
+ _example: [{ "name" => "puppetlabs-mysql" }, { "name" => "puppetlabs-apache" }]
247
+ },
230
248
  "name" => {
231
249
  description: "The name of the Bolt project. When this option is configured, the project is considered a "\
232
250
  "[Bolt project](experimental_features.md#bolt-projects), allowing Bolt to load content from "\
@@ -476,6 +494,7 @@ module Bolt
476
494
  inventoryfile
477
495
  log
478
496
  modulepath
497
+ modules
479
498
  name
480
499
  plans
481
500
  plugin_hooks
@@ -141,6 +141,19 @@ module Bolt
141
141
  end
142
142
  end
143
143
 
144
+ def detect_project_conflict(project, environment)
145
+ return unless project && project.load_as_module?
146
+ # The environment modulepath has stripped out non-existent directories,
147
+ # so we don't need to check for them
148
+ modules = environment.modulepath.flat_map do |path|
149
+ Dir.children(path).select { |name| Puppet::Module.is_module_directory?(name, path) }
150
+ end
151
+ if modules.include?(project.name)
152
+ Bolt::Logger.warn_once("project shadows module",
153
+ "The project '#{project.name}' shadows an existing module of the same name")
154
+ end
155
+ end
156
+
144
157
  # Runs a block in a PAL script compiler configured for Bolt. Catches
145
158
  # exceptions thrown by the block and re-raises them ensuring they are
146
159
  # Bolt::Errors since the script compiler block will squash all exceptions.
@@ -151,6 +164,10 @@ module Bolt
151
164
  # Only load the project if it a) exists, b) has a name it can be loaded with
152
165
  Puppet.override(bolt_project: @project,
153
166
  yaml_plan_instantiator: Bolt::PAL::YamlPlan::Loader) do
167
+ # Because this has the side effect of loading and caching the list
168
+ # of modules, it must happen *after* we have overridden
169
+ # bolt_project or the project will be ignored
170
+ detect_project_conflict(@project, Puppet.lookup(:environments).get('bolt'))
154
171
  pal.with_script_compiler(set_local_facts: false) do |compiler|
155
172
  alias_types(compiler)
156
173
  register_resource_types(Puppet.lookup(:loaders)) if @resource_types
@@ -50,6 +50,20 @@ module Bolt
50
50
  def self.create_project(path, type = 'option', logs = [])
51
51
  fullpath = Pathname.new(path).expand_path
52
52
 
53
+ if type == 'user'
54
+ begin
55
+ # This is already expanded if the type is user
56
+ FileUtils.mkdir_p(path)
57
+ rescue StandardError
58
+ logs << { warn: "Could not create default project at #{path}. Continuing without a writeable project. "\
59
+ "Log and rerun files will not be written." }
60
+ end
61
+ end
62
+
63
+ if type == 'option' && !File.directory?(path)
64
+ raise Bolt::Error.new("Could not find project at #{path}", "bolt/project-error")
65
+ end
66
+
53
67
  if !Bolt::Util.windows? && type != 'environment' && fullpath.world_writable?
54
68
  raise Bolt::Error.new(
55
69
  "Project directory '#{fullpath}' is world-writable which poses a security risk. Set "\
@@ -149,6 +163,10 @@ module Bolt
149
163
  @data['plans']
150
164
  end
151
165
 
166
+ def modules
167
+ @data['modules']
168
+ end
169
+
152
170
  def validate
153
171
  if name
154
172
  if name !~ Bolt::Module::MODULE_NAME_REGEX
@@ -170,6 +188,23 @@ module Bolt
170
188
  raise Bolt::ValidationError, "'#{conf}' in bolt-project.yaml must be an array"
171
189
  end
172
190
  end
191
+
192
+ if @data['modules']
193
+ unless @data['modules'].is_a?(Array)
194
+ raise Bolt::ValidationError, "'modules' in bolt-project.yaml must be an array"
195
+ end
196
+
197
+ @data['modules'].each do |mod|
198
+ next if mod.is_a?(Hash)
199
+ raise Bolt::ValidationError, "Module declaration #{mod.inspect} must be a hash"
200
+ end
201
+
202
+ unknown_keys = data['modules'].flat_map(&:keys).uniq - ['name']
203
+ if unknown_keys.any?
204
+ @logs << { warn: "Module declarations in bolt-project.yaml only support a name key. Ignoring "\
205
+ "unsupported keys: #{unknown_keys.join(', ')}." }
206
+ end
207
+ end
173
208
  end
174
209
 
175
210
  def check_deprecated_file
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/error'
4
+ require 'bolt/puppetfile/module'
5
+
6
+ # This class manages the logical contents of a Puppetfile. It includes methods
7
+ # for parsing a Puppetfile and its modules, resolving module dependencies,
8
+ # and writing a Puppetfile.
9
+ #
10
+ module Bolt
11
+ class Puppetfile
12
+ attr_reader :modules
13
+
14
+ def initialize(modules = [])
15
+ @modules = Set.new
16
+ add_modules(modules)
17
+ end
18
+
19
+ # Loads a Puppetfile and parses its module specifications, returning a
20
+ # Bolt::Puppetfile object with the modules set.
21
+ #
22
+ def self.parse(path)
23
+ require 'puppetfile-resolver'
24
+ require 'puppetfile-resolver/puppetfile/parser/r10k_eval'
25
+
26
+ begin
27
+ parsed = ::PuppetfileResolver::Puppetfile::Parser::R10KEval.parse(File.read(path))
28
+ rescue StandardError => e
29
+ raise Bolt::Error.new(
30
+ "Unable to parse Puppetfile #{path}: #{e.message}",
31
+ 'bolt/puppetfile-parsing'
32
+ )
33
+ end
34
+
35
+ unless parsed.valid?
36
+ raise Bolt::ValidationError,
37
+ "Unable to parse Puppetfile #{path}"
38
+ end
39
+
40
+ modules = parsed.modules.map do |mod|
41
+ Bolt::Puppetfile::Module.new(mod.owner, mod.name, mod.version)
42
+ end
43
+
44
+ new(modules)
45
+ end
46
+
47
+ # Writes a Puppetfile that includes specifications for each of the
48
+ # modules.
49
+ #
50
+ def write(path, force: false)
51
+ if File.exist?(path) && !force
52
+ raise Bolt::FileError.new(
53
+ "Cannot overwrite existing Puppetfile at #{path}. To forcibly overwrite, "\
54
+ "run with the '--force' option.",
55
+ path
56
+ )
57
+ end
58
+
59
+ File.open(path, 'w') do |file|
60
+ file.puts '# This Puppetfile is managed by Bolt. Do not edit.'
61
+ modules.each { |mod| file.puts mod.to_spec }
62
+ file.puts
63
+ end
64
+ rescue SystemCallError => e
65
+ raise Bolt::FileError.new(
66
+ "#{e.message}: unable to write Puppetfile.",
67
+ path
68
+ )
69
+ end
70
+
71
+ # Resolves module dependencies using the puppetfile-resolver library. The
72
+ # resolver will return a document model including all module dependencies
73
+ # and the latest version that can be installed for each. The document model
74
+ # is parsed and turned into a Set of Bolt::Puppetfile::Module objects.
75
+ #
76
+ def resolve
77
+ require 'puppetfile-resolver'
78
+
79
+ # Build the document model from the modules.
80
+ model = PuppetfileResolver::Puppetfile::Document.new('')
81
+
82
+ @modules.each do |mod|
83
+ model.add_module(
84
+ PuppetfileResolver::Puppetfile::ForgeModule.new(mod.title).tap do |tap|
85
+ tap.version = :latest
86
+ end
87
+ )
88
+ end
89
+
90
+ # Make sure the Puppetfile model is valid.
91
+ unless model.valid?
92
+ raise Bolt::ValidationError,
93
+ "Unable to resolve dependencies for modules: #{@modules.map(&:title).join(', ')}"
94
+ end
95
+
96
+ # Create the resolver using the Puppetfile model. nil disables Puppet
97
+ # version restrictions.
98
+ resolver = PuppetfileResolver::Resolver.new(model, nil)
99
+
100
+ # Configure and resolve the dependency graph, catching any errors
101
+ # raised by puppetfile-resolver and re-raising them as Bolt errors.
102
+ begin
103
+ result = resolver.resolve(
104
+ cache: nil,
105
+ ui: nil,
106
+ module_paths: [],
107
+ allow_missing_modules: true
108
+ )
109
+ rescue StandardError => e
110
+ raise Bolt::Error.new(e.message, 'bolt/puppetfile-resolver-error')
111
+ end
112
+
113
+ # Validate that the modules exist.
114
+ missing_graph = result.specifications.select do |_name, spec|
115
+ spec.instance_of? PuppetfileResolver::Models::MissingModuleSpecification
116
+ end
117
+
118
+ if missing_graph.any?
119
+ titles = model.modules.each_with_object({}) do |mod, acc|
120
+ acc[mod.name] = mod.title
121
+ end
122
+
123
+ names = titles.values_at(*missing_graph.keys)
124
+ plural = names.count == 1 ? '' : 's'
125
+
126
+ raise Bolt::Error.new(
127
+ "Unknown module name#{plural} #{names.join(', ')}",
128
+ 'bolt/unknown-modules'
129
+ )
130
+ end
131
+
132
+ # Filter the dependency graph to only include module specifications. This
133
+ # will only remove the Puppet version specification, which is not needed.
134
+ specs = result.specifications.select do |_name, spec|
135
+ spec.instance_of? PuppetfileResolver::Models::ModuleSpecification
136
+ end
137
+
138
+ @modules = specs.map do |_name, spec|
139
+ Bolt::Puppetfile::Module.new(spec.owner, spec.name, spec.version.to_s)
140
+ end.to_set
141
+ end
142
+
143
+ # Adds to the set of modules.
144
+ #
145
+ def add_modules(modules)
146
+ modules.each do |mod|
147
+ case mod
148
+ when Bolt::Puppetfile::Module
149
+ @modules << mod
150
+ when Hash
151
+ @modules << Bolt::Puppetfile::Module.from_hash(mod)
152
+ else
153
+ raise Bolt::ValidationError, "Module must be a Bolt::Puppetfile::Module or Hash."
154
+ end
155
+ end
156
+
157
+ @modules
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'r10k/cli'
4
+ require 'bolt/r10k_log_proxy'
5
+ require 'bolt/error'
6
+
7
+ # This class is used to install modules from a Puppetfile to a module directory.
8
+ #
9
+ module Bolt
10
+ class Puppetfile
11
+ class Installer
12
+ def initialize(config = {})
13
+ @config = config
14
+ end
15
+
16
+ def install(path, moduledir)
17
+ unless File.exist?(path)
18
+ raise Bolt::FileError.new(
19
+ "Could not find a Puppetfile at #{path}",
20
+ path
21
+ )
22
+ end
23
+
24
+ r10k_opts = {
25
+ root: File.dirname(path),
26
+ puppetfile: path.to_s,
27
+ moduledir: moduledir.to_s
28
+ }
29
+
30
+ settings = R10K::Settings.global_settings.evaluate(@config)
31
+ R10K::Initializers::GlobalInitializer.new(settings).call
32
+ install_action = R10K::Action::Puppetfile::Install.new(r10k_opts, nil)
33
+
34
+ # Override the r10k logger with a proxy to our own logger
35
+ R10K::Logging.instance_variable_set(:@outputter, Bolt::R10KLogProxy.new)
36
+
37
+ install_action.call
38
+ rescue R10K::Error => e
39
+ raise PuppetfileError, e
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/error'
4
+
5
+ # This class represents a module specification. It used by the Bolt::Puppetfile
6
+ # class to have a consistent API for accessing a module's attributes.
7
+ #
8
+ module Bolt
9
+ class Puppetfile
10
+ class Module
11
+ attr_reader :owner, :name, :version
12
+
13
+ def initialize(owner, name, version = nil)
14
+ @owner = owner
15
+ @name = name
16
+ @version = version
17
+ end
18
+
19
+ # Creates a new module from a hash.
20
+ #
21
+ def self.from_hash(mod)
22
+ unless mod['name'].is_a?(String)
23
+ raise Bolt::ValidationError,
24
+ "Module name must be a String, not #{mod['name'].inspect}"
25
+ end
26
+
27
+ owner, name = mod['name'].tr('/', '-').split('-', 2)
28
+
29
+ unless owner && name
30
+ raise Bolt::ValidationError, "Module name #{mod['name']} must include both the owner and module name."
31
+ end
32
+
33
+ new(owner, name)
34
+ end
35
+
36
+ # Returns the module's title.
37
+ #
38
+ def title
39
+ "#{@owner}-#{@name}"
40
+ end
41
+
42
+ # Checks two modules for equality.
43
+ #
44
+ def eql?(other)
45
+ self.class == other.class && @owner == other.owner && @name == other.name
46
+ end
47
+ alias == eql?
48
+
49
+ # Hashes the module.
50
+ #
51
+ def hash
52
+ [@owner, @name].hash
53
+ end
54
+
55
+ # Returns the Puppetfile specification for the module.
56
+ #
57
+ def to_spec
58
+ if @version
59
+ "mod #{title.inspect}, #{@version.inspect}"
60
+ else
61
+ "mod #{title.inspect}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -53,7 +53,7 @@ module Bolt
53
53
  end
54
54
  end
55
55
  rescue StandardError => e
56
- @logger.warn("Failed to save result to #{@path}: #{e.message}")
56
+ Bolt::Logger.warn_once('unwriteable_file', "Failed to save result to #{@path}: #{e.message}")
57
57
  end
58
58
  end
59
59
  end
@@ -66,9 +66,24 @@ module Bolt
66
66
  'details' => { 'exit_code' => exit_code } }
67
67
  end
68
68
 
69
+ if value.key?('_error')
70
+ unless value['_error'].is_a?(Hash) && value['_error'].key?('msg')
71
+ value['_error'] = {
72
+ 'msg' => "Invalid error returned from task #{task}: #{value['_error'].inspect}. Error "\
73
+ "must be an object with a msg key.",
74
+ 'kind' => 'bolt/invalid-task-error',
75
+ 'details' => { 'original_error' => value['_error'] }
76
+ }
77
+ end
78
+
79
+ value['_error']['kind'] ||= 'bolt/error'
80
+ value['_error']['details'] ||= {}
81
+ end
82
+
69
83
  if value.key?('_sensitive')
70
84
  value['_sensitive'] = Puppet::Pops::Types::PSensitiveType::Sensitive.new(value['_sensitive'])
71
85
  end
86
+
72
87
  new(target, value: value, action: 'task', object: task)
73
88
  end
74
89
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bolt
4
- VERSION = '2.26.0'
4
+ VERSION = '2.27.0'
5
5
  end
@@ -7,7 +7,7 @@ module BoltServer
7
7
  class BaseConfig
8
8
  def config_keys
9
9
  %w[host port ssl-cert ssl-key ssl-ca-cert
10
- ssl-cipher-suites loglevel logfile whitelist]
10
+ ssl-cipher-suites loglevel logfile whitelist projects-dir]
11
11
  end
12
12
 
13
13
  def env_keys
@@ -7,7 +7,7 @@ require 'bolt/error'
7
7
  module BoltServer
8
8
  class Config < BoltServer::BaseConfig
9
9
  def config_keys
10
- super + %w[concurrency cache-dir file-server-conn-timeout file-server-uri]
10
+ super + %w[concurrency cache-dir file-server-conn-timeout file-server-uri projects-dir]
11
11
  end
12
12
 
13
13
  def env_keys
@@ -5,6 +5,7 @@ require 'addressable/uri'
5
5
  require 'bolt'
6
6
  require 'bolt/error'
7
7
  require 'bolt/inventory'
8
+ require 'bolt/project'
8
9
  require 'bolt/target'
9
10
  require 'bolt_server/file_cache'
10
11
  require 'bolt/task/puppet_server'
@@ -191,20 +192,44 @@ module BoltServer
191
192
  end
192
193
 
193
194
  def in_pe_pal_env(environment)
194
- if environment.nil?
195
- [400, '`environment` is a required argument']
196
- else
197
- @pal_mutex.synchronize do
198
- pal = BoltServer::PE::PAL.new({}, environment)
199
- yield pal
200
- rescue Puppet::Environments::EnvironmentNotFound
201
- [400, {
202
- "class" => 'bolt/unknown-environment',
203
- "message" => "Environment #{environment} not found"
204
- }.to_json]
205
- rescue Bolt::Error => e
206
- [400, e.to_json]
207
- end
195
+ return [400, '`environment` is a required argument'] if environment.nil?
196
+ @pal_mutex.synchronize do
197
+ pal = BoltServer::PE::PAL.new({}, environment)
198
+ yield pal
199
+ rescue Puppet::Environments::EnvironmentNotFound
200
+ [400, {
201
+ "class" => 'bolt/unknown-environment',
202
+ "message" => "Environment #{environment} not found"
203
+ }.to_json]
204
+ rescue Bolt::Error => e
205
+ [400, e.to_json]
206
+ end
207
+ end
208
+
209
+ def in_bolt_project(bolt_project)
210
+ return [400, '`project_ref` is a required argument'] if bolt_project.nil?
211
+ project_dir = File.join(@config['projects-dir'], bolt_project)
212
+ return [400, "`project_ref`: #{project_dir} does not exist"] unless Dir.exist?(project_dir)
213
+ @pal_mutex.synchronize do
214
+ project = Bolt::Project.create_project(project_dir)
215
+ bolt_config = Bolt::Config.from_project(project, {})
216
+ pal = Bolt::PAL.new(bolt_config.modulepath, nil, nil, nil, nil, nil, bolt_config.project)
217
+ module_path = [
218
+ BoltServer::PE::PAL::PE_BOLTLIB_PATH,
219
+ Bolt::PAL::BOLTLIB_PATH,
220
+ *bolt_config.modulepath,
221
+ Bolt::PAL::MODULES_PATH
222
+ ]
223
+ # CODEREVIEW: I *think* this is the only thing we need to make different between bolt's PAL. Is it acceptable
224
+ # to hack this? Modulepath is currently a readable attribute, could we make it writeable?
225
+ pal.instance_variable_set(:@modulepath, module_path)
226
+ context = {
227
+ pal: pal,
228
+ config: bolt_config
229
+ }
230
+ yield context
231
+ rescue Bolt::Error => e
232
+ [400, e.to_json]
208
233
  end
209
234
  end
210
235
 
@@ -221,14 +246,12 @@ module BoltServer
221
246
  plan_info
222
247
  end
223
248
 
224
- def build_puppetserver_uri(file_identifier, module_name, environment)
249
+ def build_puppetserver_uri(file_identifier, module_name, parameters)
225
250
  segments = file_identifier.split('/', 3)
226
251
  if segments.size == 1
227
252
  {
228
253
  'path' => "/puppet/v3/file_content/tasks/#{module_name}/#{file_identifier}",
229
- 'params' => {
230
- 'environment' => environment
231
- }
254
+ 'params' => parameters
232
255
  }
233
256
  else
234
257
  module_segment, mount_segment, name_segment = *segments
@@ -241,14 +264,12 @@ module BoltServer
241
264
  when 'lib'
242
265
  "/puppet/v3/file_content/plugins/#{name_segment}"
243
266
  end,
244
- 'params' => {
245
- 'environment' => environment
246
- }
267
+ 'params' => parameters
247
268
  }
248
269
  end
249
270
  end
250
271
 
251
- def pe_task_info(pal, module_name, task_name, environment)
272
+ def pe_task_info(pal, module_name, task_name, parameters)
252
273
  # Handle case where task name is simply module name with special `init` task
253
274
  task_name = if task_name == 'init' || task_name.nil?
254
275
  module_name
@@ -261,7 +282,7 @@ module BoltServer
261
282
  'filename' => file_hash['name'],
262
283
  'sha256' => Digest::SHA256.hexdigest(File.read(file_hash['path'])),
263
284
  'size_bytes' => File.size(file_hash['path']),
264
- 'uri' => build_puppetserver_uri(file_hash['name'], module_name, environment)
285
+ 'uri' => build_puppetserver_uri(file_hash['name'], module_name, parameters)
265
286
  }
266
287
  end
267
288
  {
@@ -271,6 +292,21 @@ module BoltServer
271
292
  }
272
293
  end
273
294
 
295
+ def allowed_helper(metadata, allowlist)
296
+ allowed = allowlist.nil? || allowlist.include?(metadata['name']) ? true : false
297
+ metadata.merge({ 'allowed' => allowed })
298
+ end
299
+
300
+ def task_list(pal)
301
+ tasks = pal.list_tasks
302
+ tasks.map { |task_name, _description| { 'name' => task_name } }
303
+ end
304
+
305
+ def plan_list(pal)
306
+ plans = pal.list_plans.flatten
307
+ plans.map { |plan_name| { 'name' => plan_name } }
308
+ end
309
+
274
310
  get '/' do
275
311
  200
276
312
  end
@@ -401,12 +437,40 @@ module BoltServer
401
437
  end
402
438
  end
403
439
 
440
+ # Fetches the metadata for a single plan
441
+ #
442
+ # @param project_ref [String] the project to fetch the plan from
443
+ get '/project_plans/:module_name/:plan_name' do
444
+ in_bolt_project(params['project_ref']) do |context|
445
+ plan_info = pe_plan_info(context[:pal], params[:module_name], params[:plan_name])
446
+ plan_info = allowed_helper(plan_info, context[:config].project.plans)
447
+ [200, plan_info.to_json]
448
+ end
449
+ end
450
+
404
451
  # Fetches the metadata for a single task
405
452
  #
406
453
  # @param environment [String] the environment to fetch the task from
407
454
  get '/tasks/:module_name/:task_name' do
408
455
  in_pe_pal_env(params['environment']) do |pal|
409
- task_info = pe_task_info(pal, params[:module_name], params[:task_name], params['environment'])
456
+ ps_parameters = {
457
+ 'environment' => params['environment']
458
+ }
459
+ task_info = pe_task_info(pal, params[:module_name], params[:task_name], ps_parameters)
460
+ [200, task_info.to_json]
461
+ end
462
+ end
463
+
464
+ # Fetches the metadata for a single task
465
+ #
466
+ # @param bolt_project_ref [String] the reference to the bolt-project directory to load task metadata from
467
+ get '/project_tasks/:module_name/:task_name' do
468
+ in_bolt_project(params['project_ref']) do |context|
469
+ ps_parameters = {
470
+ 'project' => params['project_ref']
471
+ }
472
+ task_info = pe_task_info(context[:pal], params[:module_name], params[:task_name], ps_parameters)
473
+ task_info = allowed_helper(task_info, context[:config].project.tasks)
410
474
  [200, task_info.to_json]
411
475
  end
412
476
  end
@@ -435,13 +499,30 @@ module BoltServer
435
499
  end
436
500
  end
437
501
 
502
+ # Fetches the list of plans for a project
503
+ #
504
+ # @param project_ref [String] the project to fetch the list of plans from
505
+ get '/project_plans' do
506
+ in_bolt_project(params['project_ref']) do |context|
507
+ plans_response = plan_list(context[:pal])
508
+
509
+ # Dig in context for the allowlist of plans from project object
510
+ plans_response.map! { |metadata| allowed_helper(metadata, context[:config].project.plans) }
511
+
512
+ # We structure this array of plans to be an array of hashes so that it matches the structure
513
+ # returned by the puppetserver API that serves data like this. Structuring the output this way
514
+ # makes switching between puppetserver and bolt-server easier, which makes changes to switch
515
+ # to bolt-server smaller/simpler.
516
+ [200, plans_response.to_json]
517
+ end
518
+ end
519
+
438
520
  # Fetches the list of tasks for an environment
439
521
  #
440
522
  # @param environment [String] the environment to fetch the list of tasks from
441
523
  get '/tasks' do
442
524
  in_pe_pal_env(params['environment']) do |pal|
443
- tasks = pal.list_tasks
444
- tasks_response = tasks.map { |task_name, _description| { 'name' => task_name } }.to_json
525
+ tasks_response = task_list(pal).to_json
445
526
 
446
527
  # We structure this array of tasks to be an array of hashes so that it matches the structure
447
528
  # returned by the puppetserver API that serves data like this. Structuring the output this way
@@ -451,6 +532,24 @@ module BoltServer
451
532
  end
452
533
  end
453
534
 
535
+ # Fetches the list of tasks for a bolt-project
536
+ #
537
+ # @param project_ref [String] the project to fetch the list of tasks from
538
+ get '/project_tasks' do
539
+ in_bolt_project(params['project_ref']) do |context|
540
+ tasks_response = task_list(context[:pal])
541
+
542
+ # Dig in context for the allowlist of tasks from project object
543
+ tasks_response.map! { |metadata| allowed_helper(metadata, context[:config].project.tasks) }
544
+
545
+ # We structure this array of tasks to be an array of hashes so that it matches the structure
546
+ # returned by the puppetserver API that serves data like this. Structuring the output this way
547
+ # makes switching between puppetserver and bolt-server easier, which makes changes to switch
548
+ # to bolt-server smaller/simpler.
549
+ [200, tasks_response.to_json]
550
+ end
551
+ end
552
+
454
553
  error 404 do
455
554
  err = Bolt::Error.new("Could not find route #{request.path}",
456
555
  'boltserver/not-found')
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bolt
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.26.0
4
+ version: 2.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Puppet
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-08-31 00:00:00.000000000 Z
11
+ date: 2020-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -498,6 +498,9 @@ files:
498
498
  - lib/bolt/puppetdb.rb
499
499
  - lib/bolt/puppetdb/client.rb
500
500
  - lib/bolt/puppetdb/config.rb
501
+ - lib/bolt/puppetfile.rb
502
+ - lib/bolt/puppetfile/installer.rb
503
+ - lib/bolt/puppetfile/module.rb
501
504
  - lib/bolt/r10k_log_proxy.rb
502
505
  - lib/bolt/rerun.rb
503
506
  - lib/bolt/resource_instance.rb