From 35a4a8188c5dfef0df350c76b1d4e7ff8ceecfd0 Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 19:00:17 +0000 Subject: [PATCH 1/8] Add 'puppetfile' option to bolt-project.yaml Signed-off-by: Chris Tessmer --- lib/bolt/config/options.rb | 9 +++++++++ lib/bolt/project.rb | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/lib/bolt/config/options.rb b/lib/bolt/config/options.rb index ed59be3d7..0c39ea822 100644 --- a/lib/bolt/config/options.rb +++ b/lib/bolt/config/options.rb @@ -484,6 +484,14 @@ module Options }, _plugin: true }, + "puppetfile" => { + description: "The path to the project's Puppetfile, used by `bolt module install` and " \ + "related commands. Relative paths are resolved from the project directory; " \ + "absolute paths are used as-is. Defaults to `Puppetfile` in the project directory.", + type: String, + _example: "control-repo/Puppetfile", + _plugin: true + }, "rerunfile" => { description: "The path to the project's rerun file. The rerun file is used to store information " \ "about targets from the most recent run. Expands relative to the project directory.", @@ -668,6 +676,7 @@ module Options policies puppetdb puppetdb-instances + puppetfile rerunfile save-rerun spinner diff --git a/lib/bolt/project.rb b/lib/bolt/project.rb index e7e9b2546..f4c642d04 100644 --- a/lib/bolt/project.rb +++ b/lib/bolt/project.rb @@ -132,6 +132,10 @@ def initialize(data, path, type = 'option') @rerunfile = File.expand_path(@data['rerunfile'], @path) end + if @data['puppetfile'] + @puppetfile = Pathname.new(File.expand_path(@data['puppetfile'], @path)) + end + validate if project_file? end From 9d28a52c6a13b573f0a0ceed6292238accd1a035 Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 19:18:47 +0000 Subject: [PATCH 2/8] Ensure Puppetfile directory exists Signed-off-by: Chris Tessmer --- lib/bolt/module_installer/puppetfile.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/bolt/module_installer/puppetfile.rb b/lib/bolt/module_installer/puppetfile.rb index 24f0a2f47..1c09ff336 100644 --- a/lib/bolt/module_installer/puppetfile.rb +++ b/lib/bolt/module_installer/puppetfile.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'fileutils' + require_relative '../../bolt/error' require_relative 'puppetfile/forge_module' require_relative 'puppetfile/git_module' @@ -69,6 +71,7 @@ def self.parse(path, skip_unsupported_modules: false) # modules. # def write(path, moduledir = nil) + FileUtils.mkdir_p(File.dirname(path)) File.open(path, 'w') do |file| if moduledir file.puts "# This Puppetfile is managed by Bolt. Do not edit." From 87a098c2e40d37036f66ef8a0194f3f5b83579b3 Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 19:56:04 +0000 Subject: [PATCH 3/8] Add unit tests Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- spec/unit/project_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/spec/unit/project_spec.rb b/spec/unit/project_spec.rb index 19a8b14ce..7bf99e1de 100644 --- a/spec/unit/project_spec.rb +++ b/spec/unit/project_spec.rb @@ -81,6 +81,44 @@ end end + describe "#puppetfile" do + context "without a puppetfile override" do + let(:project_config) { {} } + + it "defaults to /Puppetfile" do + expect(project.puppetfile).to be_a(Pathname) + expect(project.puppetfile).to eq(project.path + 'Puppetfile') + end + end + + context "with a relative puppetfile override" do + let(:project_config) { { 'puppetfile' => 'custom/Puppetfile.dev' } } + + it "resolves the path relative to the project directory" do + expect(project.puppetfile).to be_a(Pathname) + expect(project.puppetfile).to eq(project.path + 'custom' + 'Puppetfile.dev') + end + end + + context "with an absolute puppetfile override" do + let(:project_config) { { 'puppetfile' => '/tmp/shared/Puppetfile' } } + + it "uses the absolute path verbatim" do + expect(project.puppetfile).to be_a(Pathname) + expect(project.puppetfile).to eq(Pathname.new('/tmp/shared/Puppetfile')) + end + end + + context "with a non-string puppetfile value" do + let(:project_config) { { 'puppetfile' => 42 } } + + it "fails project schema validation" do + expect { Bolt::Project.create_project(@project_path) } + .to raise_error(Bolt::ValidationError, /puppetfile/) + end + end + end + describe "with namespaced project names" do let(:project_config) { { 'name' => 'puppetlabs-foo' } } From c2abc116e97577fde6dee65184a876975a5995ef Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 20:00:05 +0000 Subject: [PATCH 4/8] Add integration tests Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- .../module_installer/module_installer_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/integration/module_installer/module_installer_spec.rb b/spec/integration/module_installer/module_installer_spec.rb index abf1ebbbd..0a040964c 100644 --- a/spec/integration/module_installer/module_installer_spec.rb +++ b/spec/integration/module_installer/module_installer_spec.rb @@ -57,6 +57,22 @@ end end + context 'with a puppetfile override in bolt-project.yaml' do + let(:project_config) { { 'puppetfile' => 'custom/Puppetfile' } } + + it 'writes the puppetfile at the override path and not the default' do + output = run_cli_json(%w[module add puppetlabs-yaml], project: project) + expect(output['puppetfile']).to eq((project.path + 'custom' + 'Puppetfile').to_s) + expect(output['success']).to be + + expect(File.exist?(project.path + 'custom' + 'Puppetfile')).to be true + expect(File.exist?(project.path + 'Puppetfile')).to be false + + puppetfile_content = File.read(project.path + 'custom' + 'Puppetfile') + expect(puppetfile_content.lines).to include(%r{mod 'puppetlabs/yaml'}) + end + end + context 'with install configuration' do let(:base_config) do { From 5fe05aff08791bf05f5ef41aec795375a1e689fb Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 20:14:05 +0000 Subject: [PATCH 5/8] Regen bolt-project schema Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- schemas/bolt-project.schema.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/schemas/bolt-project.schema.json b/schemas/bolt-project.schema.json index d65c33b33..b43c95ff2 100644 --- a/schemas/bolt-project.schema.json +++ b/schemas/bolt-project.schema.json @@ -67,6 +67,9 @@ "puppetdb-instances": { "$ref": "#/definitions/puppetdb-instances" }, + "puppetfile": { + "$ref": "#/definitions/puppetfile" + }, "rerunfile": { "$ref": "#/definitions/rerunfile" }, @@ -605,6 +608,17 @@ } ] }, + "puppetfile": { + "description": "The path to the project's Puppetfile, used by `bolt module install` and related commands. Relative paths are resolved from the project directory; absolute paths are used as-is. Defaults to `Puppetfile` in the project directory.", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/_plugin" + } + ] + }, "rerunfile": { "description": "The path to the project's rerun file. The rerun file is used to store information about targets from the most recent run. Expands relative to the project directory.", "oneOf": [ From 4f0ff4a837e58e3d22e5018a4568bfdfde0b0913 Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 22:30:15 +0000 Subject: [PATCH 6/8] Avoid migrator collision with ancient Hash option Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- lib/bolt/project.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bolt/project.rb b/lib/bolt/project.rb index f4c642d04..21ade8344 100644 --- a/lib/bolt/project.rb +++ b/lib/bolt/project.rb @@ -132,7 +132,7 @@ def initialize(data, path, type = 'option') @rerunfile = File.expand_path(@data['rerunfile'], @path) end - if @data['puppetfile'] + if @data['puppetfile'].is_a?(String) @puppetfile = Pathname.new(File.expand_path(@data['puppetfile'], @path)) end From cbc4c16d5e8e98bc17e5fd1cea4606a50890030d Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Tue, 30 Jun 2026 22:57:52 +0000 Subject: [PATCH 7/8] Fix absolute path test when run on Windows hosts The spec tests fails when run ion Windows hosts, because the Windows Ruby `File.expand_path()` prepends the drive letter. The spec test now does expands both sides so the comparison is sound regardless of OS. Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- spec/unit/project_spec.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/unit/project_spec.rb b/spec/unit/project_spec.rb index 7bf99e1de..e585f863c 100644 --- a/spec/unit/project_spec.rb +++ b/spec/unit/project_spec.rb @@ -101,11 +101,14 @@ end context "with an absolute puppetfile override" do - let(:project_config) { { 'puppetfile' => '/tmp/shared/Puppetfile' } } + # File.expand_path on Windows prepends the cwd's drive letter to a + # Unix-style absolute, so feed the same expansion through both sides. + let(:absolute_path) { File.expand_path('/tmp/shared/Puppetfile') } + let(:project_config) { { 'puppetfile' => absolute_path } } it "uses the absolute path verbatim" do expect(project.puppetfile).to be_a(Pathname) - expect(project.puppetfile).to eq(Pathname.new('/tmp/shared/Puppetfile')) + expect(project.puppetfile).to eq(Pathname.new(absolute_path)) end end From 6e257db173ea26ba0ebd5dbaa00ed5d93b02b425 Mon Sep 17 00:00:00 2001 From: Chris Tessmer Date: Wed, 1 Jul 2026 17:44:13 +0000 Subject: [PATCH 8/8] Raise error if puppetfile opt isn't in project dir Throw a validation error if a bolt-project.yaml 'puppetfile' option isn't a relative path that resolves to inside the poroject directory. Assisted-by: Claude Opus 4.7 Signed-off-by: Chris Tessmer --- lib/bolt/project.rb | 7 ++++++- spec/unit/project_spec.rb | 20 ++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/bolt/project.rb b/lib/bolt/project.rb index 21ade8344..761a9bc5f 100644 --- a/lib/bolt/project.rb +++ b/lib/bolt/project.rb @@ -133,7 +133,12 @@ def initialize(data, path, type = 'option') end if @data['puppetfile'].is_a?(String) - @puppetfile = Pathname.new(File.expand_path(@data['puppetfile'], @path)) + expanded = File.expand_path(@data['puppetfile'], @path) + unless expanded.start_with?(@path.to_s + '/') + raise Bolt::ValidationError, + "Option 'puppetfile' must be a relative path within the project directory." + end + @puppetfile = Pathname.new(expanded) end validate if project_file? diff --git a/spec/unit/project_spec.rb b/spec/unit/project_spec.rb index e585f863c..f263c5c94 100644 --- a/spec/unit/project_spec.rb +++ b/spec/unit/project_spec.rb @@ -100,15 +100,19 @@ end end - context "with an absolute puppetfile override" do - # File.expand_path on Windows prepends the cwd's drive letter to a - # Unix-style absolute, so feed the same expansion through both sides. - let(:absolute_path) { File.expand_path('/tmp/shared/Puppetfile') } - let(:project_config) { { 'puppetfile' => absolute_path } } + context "with an absolute puppetfile path" do + let(:project_config) { { 'puppetfile' => File.expand_path('/tmp/shared/Puppetfile') } } - it "uses the absolute path verbatim" do - expect(project.puppetfile).to be_a(Pathname) - expect(project.puppetfile).to eq(Pathname.new(absolute_path)) + it "raises a validation error" do + expect { project }.to raise_error(Bolt::ValidationError, /must be a relative path within the project directory/) + end + end + + context "with a puppetfile path that escapes the project directory" do + let(:project_config) { { 'puppetfile' => '../outside/Puppetfile' } } + + it "raises a validation error" do + expect { project }.to raise_error(Bolt::ValidationError, /must be a relative path within the project directory/) end end