Skip to content

Commit 3c0c9e1

Browse files
committed
CHEF-34003: Support ~/.chef/ruby/<version>/gems for Habitat gem persistence
Modify the chef gem command to use ~/.chef/ruby/<ruby_version>/gems as the default GEM_HOME when running inside a Habitat-based Workstation environment. This ensures all user-installed gems persist across Workstation package upgrades. Changes: - GemForwarder sets GEM_HOME/GEM_PATH to user gem dir in Habitat mode - habitat_user_gem_dir helper returns version-specific path - habitat_env updated to include user gem dir in PATH/GEM_PATH - plan.sh wrapper resolves Ruby ABI version at runtime - plan.ps1 sets CHEF_GEM_HOME_ENABLED for Ruby-level handling - binstub_patch.rb includes user gem dir in GEM_PATH - plan.sh adds bundle config unset with to avoid build conflicts Signed-off-by: nitin sanghi <nsanghi@progress.com>
1 parent 3f37420 commit 3c0c9e1

8 files changed

Lines changed: 248 additions & 22 deletions

File tree

binstub_patch.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
unless ENV["APPBUNDLER_ALLOW_RVM"]
22
ENV["APPBUNDLER_ALLOW_RVM"] = "true"
3-
ENV["GEM_PATH"] = [File.expand_path(File.join(__dir__, "..", "vendor")), ENV["GEM_PATH"]].compact.join(File::PATH_SEPARATOR)
3+
user_gem_home = File.expand_path(File.join("~", ".chef", "ruby", RbConfig::CONFIG["ruby_version"], "gems"))
4+
ENV["GEM_PATH"] = [user_gem_home, File.expand_path(File.join(__dir__, "..", "vendor")), ENV["GEM_PATH"]].compact.join(File::PATH_SEPARATOR)
45
end

habitat/plan.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ function Invoke-SetupEnvironment {
3636
Set-RuntimeEnv FORCE_FFI_YAJL "ext"
3737
Set-RuntimeEnv LANG "en_US.UTF-8"
3838
Set-RuntimeEnv LC_CTYPE "en_US.UTF-8"
39+
40+
# Allow user-installed gems to persist across package upgrades.
41+
# The actual GEM_HOME/GEM_PATH will be resolved at runtime to include
42+
# ~/.chef/ruby/<ruby_version>/gems via the chef-cli Ruby code.
43+
Set-RuntimeEnv CHEF_GEM_HOME_ENABLED "true"
3944
}
4045

4146
function Invoke-Build {

habitat/plan.sh

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ do_setup_environment() {
1717
set_runtime_env APPBUNDLER_ALLOW_RVM "true" # prevent appbundler from clearing out the carefully constructed runtime GEM_PATH
1818
set_runtime_env LANG "en_US.UTF-8"
1919
set_runtime_env LC_CTYPE "en_US.UTF-8"
20+
21+
# Allow user-installed gems to persist across package upgrades.
22+
# The actual GEM_HOME/GEM_PATH will be resolved at runtime via the wrapper
23+
# script to include ~/.chef/ruby/<ruby_version>/gems.
24+
set_runtime_env CHEF_GEM_HOME_ENABLED "true"
2025
}
2126

2227
do_prepare() {
@@ -43,6 +48,7 @@ do_build() {
4348

4449
build_line "Setting GEM_PATH=$GEM_HOME"
4550
export GEM_PATH="$GEM_HOME"
51+
bundle config unset with
4652
bundle config --local without integration deploy maintenance test development profile
4753
bundle config --local jobs 4
4854
bundle config --local retry 5
@@ -89,10 +95,17 @@ do_install() {
8995
#!$(pkg_path_for core/bash)/bin/bash
9096
set -e
9197
92-
export PATH="$(pkg_path_for ${ruby_pkg})/bin:/sbin:/usr/sbin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:$pkg_prefix/vendor/bin:\$PATH"
98+
# Determine Ruby version for user gem path
99+
RUBY_ABI_VERSION=\$($(pkg_path_for ${ruby_pkg})/bin/ruby -e 'puts RbConfig::CONFIG["ruby_version"]')
100+
USER_GEM_HOME="\${HOME}/.chef/ruby/\${RUBY_ABI_VERSION}/gems"
101+
102+
# Create user gem directory if it does not exist
103+
mkdir -p "\${USER_GEM_HOME}"
104+
105+
export PATH="$(pkg_path_for ${ruby_pkg})/bin:/sbin:/usr/sbin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:\${USER_GEM_HOME}/bin:$pkg_prefix/vendor/bin:\$PATH"
93106
export LD_LIBRARY_PATH="$(pkg_path_for core/libarchive)/lib:\$LD_LIBRARY_PATH"
94-
export GEM_HOME="$pkg_prefix/vendor"
95-
export GEM_PATH="$pkg_prefix/vendor"
107+
export GEM_HOME="\${USER_GEM_HOME}"
108+
export GEM_PATH="\${USER_GEM_HOME}:$pkg_prefix/vendor"
96109
97110
exec $(pkg_path_for ${ruby_pkg})/bin/ruby $pkg_prefix/libexec/chef-cli "\$@"
98111
EOF

lib/chef-cli/command/gem.rb

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
#
2-
# Copyright (c) 2019-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
1+
# Copyright:: (c) 2019-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
32
# License:: Apache License, Version 2.0
43
#
54
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -20,28 +19,44 @@
2019
require "rubygems" unless defined?(Gem)
2120
require "rubygems/gem_runner"
2221
require "rubygems/exceptions"
22+
require "fileutils" unless defined?(FileUtils)
2323

2424
module ChefCLI
2525
module Command
26-
2726
# Forwards all commands to rubygems.
2827
class GemForwarder < ChefCLI::Command::Base
2928
banner "Usage: #{ChefCLI::Dist::EXEC} gem GEM_COMMANDS_AND_OPTIONS"
3029

3130
def run(params)
32-
retval = Gem::GemRunner.new.run( params.clone )
31+
setup_gem_environment if habitat_install?
32+
retval = Gem::GemRunner.new.run(params.clone)
3333
retval.nil? || retval
3434
rescue Gem::SystemExitException => e
35-
exit( e.exit_code )
35+
exit(e.exit_code)
3636
end
3737

3838
# Lazy solution: By automatically returning false, we force ChefCLI::Base to
3939
# call this class' run method, so that Gem::GemRunner can handle the -v flag
4040
# appropriately (showing the gem version, or installing a specific version
4141
# of a gem).
42-
def needs_version?(params)
42+
def needs_version?(_params)
4343
false
4444
end
45+
46+
private
47+
48+
# Sets up GEM_HOME and GEM_PATH to use ~/.chef/ruby/<ruby_version>/gems
49+
# when running inside a Habitat-based environment. This ensures gems
50+
# persist across Workstation upgrades since the Habitat package path
51+
# changes on each upgrade.
52+
def setup_gem_environment
53+
gem_dir = habitat_user_gem_dir
54+
FileUtils.mkdir_p(gem_dir) unless Dir.exist?(gem_dir)
55+
56+
ENV["GEM_HOME"] = gem_dir
57+
ENV["GEM_PATH"] = [gem_dir, habitat_env["GEM_PATH"]].compact.join(File::PATH_SEPARATOR)
58+
Gem.clear_paths
59+
end
4560
end
4661
end
4762
end

lib/chef-cli/helpers.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,25 @@ def habitat_env(show_warning: false)
173173
raise "Error: Could not determine the vendor package prefix. Ensure #{ChefCLI::Dist::HAB_PKG_NAME} is installed and CHEF_CLI_VERSION is set correctly." unless vendor_pkg_prefix
174174

175175
vendor_dir = File.join(vendor_pkg_prefix, "vendor")
176+
177+
# User gem directory for persistent gem storage across upgrades
178+
user_gem_dir = habitat_user_gem_dir
179+
176180
# Construct PATH including Ruby bin directory for chef-cli exec command
177181
ruby_bin_dir = File.dirname(RbConfig.ruby)
178182
path = [
179183
File.join(bin_pkg_prefix, "bin"),
180184
File.join(vendor_dir, "bin"),
185+
File.join(user_gem_dir, "bin"),
181186
ruby_bin_dir, # Add Ruby bin directory so exec can find gem etc.
182187
ENV["PATH"].split(File::PATH_SEPARATOR), # Preserve existing PATH
183188
].flatten.uniq
184189

185190
{
186191
"PATH" => path.join(File::PATH_SEPARATOR),
187192
"GEM_ROOT" => Gem.default_dir, # Default directory for gems
188-
"GEM_HOME" => vendor_dir, # Set only if vendor_dir exists
189-
"GEM_PATH" => vendor_dir, # Set only if vendor_dir exists
193+
"GEM_HOME" => user_gem_dir, # User-local gem dir for persistence
194+
"GEM_PATH" => [user_gem_dir, vendor_dir].join(File::PATH_SEPARATOR),
190195
}
191196
end
192197
end
@@ -216,6 +221,14 @@ def get_pkg_prefix(pkg_name)
216221
path if !path.empty? && Dir.exist?(path) # Return path only if it exists
217222
end
218223

224+
# Returns the user-local gem directory for the current Ruby version
225+
# under ~/.chef/ruby/<MAJOR.MINOR.0>/gems
226+
# This path persists across Habitat package upgrades.
227+
def habitat_user_gem_dir
228+
ruby_version = RbConfig::CONFIG["ruby_version"] # e.g., "3.1.0"
229+
File.expand_path(File.join("~", ".chef", "ruby", ruby_version, "gems"))
230+
end
231+
219232
def omnibus_expand_path(*paths)
220233
dir = File.expand_path(File.join(paths))
221234
raise OmnibusInstallNotFound.new unless dir && File.directory?(dir)

spec/unit/command/gem_spec.rb

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#
2+
# Copyright:: (c) 2019-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3+
# License:: Apache License, Version 2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
require "spec_helper"
19+
require "chef-cli/command/gem"
20+
21+
describe ChefCLI::Command::GemForwarder do
22+
let(:command_instance) { described_class.new }
23+
let(:gem_runner) { instance_double(Gem::GemRunner) }
24+
let(:ruby_version) { RbConfig::CONFIG["ruby_version"] }
25+
let(:expected_gem_dir) { File.expand_path("~/.chef/ruby/#{ruby_version}/gems") }
26+
27+
before do
28+
allow(Gem::GemRunner).to receive(:new).and_return(gem_runner)
29+
end
30+
31+
it "has a usage banner" do
32+
expect(command_instance.banner).to eq("Usage: chef gem GEM_COMMANDS_AND_OPTIONS")
33+
end
34+
35+
describe "#needs_version?" do
36+
it "returns false to let GemRunner handle version flag" do
37+
expect(command_instance.needs_version?([])).to be(false)
38+
end
39+
40+
it "returns false even with -v parameter" do
41+
expect(command_instance.needs_version?(["-v"])).to be(false)
42+
end
43+
end
44+
45+
describe "#run" do
46+
context "when NOT in a Habitat environment" do
47+
before do
48+
allow(command_instance).to receive(:habitat_install?).and_return(false)
49+
end
50+
51+
it "forwards params to Gem::GemRunner" do
52+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
53+
expect(command_instance.run(%w(install knife))).to eq(true)
54+
end
55+
56+
it "does not modify GEM_HOME" do
57+
expect(gem_runner).to receive(:run).with(%w(list)).and_return(true)
58+
expect(ENV).not_to receive(:[]=).with("GEM_HOME", anything)
59+
command_instance.run(%w(list))
60+
end
61+
62+
it "returns true when GemRunner returns nil" do
63+
expect(gem_runner).to receive(:run).with(%w(list)).and_return(nil)
64+
expect(command_instance.run(%w(list))).to eq(true)
65+
end
66+
end
67+
68+
context "when in a Habitat environment" do
69+
let(:vendor_dir) { "/hab/pkgs/chef/chef-cli/1.0.0/123/vendor" }
70+
let(:habitat_env_hash) do
71+
{
72+
"PATH" => "/hab/pkgs/chef/chef-cli/1.0.0/123/bin",
73+
"GEM_ROOT" => Gem.default_dir,
74+
"GEM_HOME" => expected_gem_dir,
75+
"GEM_PATH" => "#{expected_gem_dir}:#{vendor_dir}",
76+
}
77+
end
78+
79+
before do
80+
allow(command_instance).to receive(:habitat_install?).and_return(true)
81+
allow(command_instance).to receive(:habitat_user_gem_dir).and_return(expected_gem_dir)
82+
allow(command_instance).to receive(:habitat_env).and_return(habitat_env_hash)
83+
allow(Dir).to receive(:exist?).with(expected_gem_dir).and_return(true)
84+
allow(Gem).to receive(:clear_paths)
85+
end
86+
87+
it "sets GEM_HOME to user gem directory" do
88+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
89+
expect(ENV).to receive(:[]=).with("GEM_HOME", expected_gem_dir)
90+
allow(ENV).to receive(:[]=).with("GEM_PATH", anything)
91+
command_instance.run(%w(install knife))
92+
end
93+
94+
it "sets GEM_PATH to include both user gem dir and habitat vendor dir" do
95+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
96+
allow(ENV).to receive(:[]=).with("GEM_HOME", expected_gem_dir)
97+
expected_gem_path = "#{expected_gem_dir}:#{expected_gem_dir}:#{vendor_dir}"
98+
expect(ENV).to receive(:[]=).with("GEM_PATH", expected_gem_path)
99+
command_instance.run(%w(install knife))
100+
end
101+
102+
it "clears Gem paths after setting environment" do
103+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
104+
allow(ENV).to receive(:[]=)
105+
expect(Gem).to receive(:clear_paths)
106+
command_instance.run(%w(install knife))
107+
end
108+
109+
it "creates the gem directory if it doesn't exist" do
110+
allow(Dir).to receive(:exist?).with(expected_gem_dir).and_return(false)
111+
expect(FileUtils).to receive(:mkdir_p).with(expected_gem_dir)
112+
allow(ENV).to receive(:[]=)
113+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
114+
command_instance.run(%w(install knife))
115+
end
116+
117+
it "does not create the gem directory if it already exists" do
118+
allow(Dir).to receive(:exist?).with(expected_gem_dir).and_return(true)
119+
expect(FileUtils).not_to receive(:mkdir_p)
120+
allow(ENV).to receive(:[]=)
121+
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
122+
command_instance.run(%w(install knife))
123+
end
124+
125+
it "forwards all gem subcommands correctly" do
126+
allow(ENV).to receive(:[]=)
127+
%w(install list uninstall source search update).each do |subcmd|
128+
expect(gem_runner).to receive(:run).with([subcmd]).and_return(true)
129+
expect(command_instance.run([subcmd])).to eq(true)
130+
end
131+
end
132+
end
133+
134+
context "when GemRunner raises Gem::SystemExitException" do
135+
before do
136+
allow(command_instance).to receive(:habitat_install?).and_return(false)
137+
end
138+
139+
it "exits with the exception's exit code" do
140+
exception = Gem::SystemExitException.new(1)
141+
allow(gem_runner).to receive(:run).and_raise(exception)
142+
expect { command_instance.run(%w(install bad_gem)) }.to raise_error(SystemExit) { |e|
143+
expect(e.status).to eq(1)
144+
}
145+
end
146+
end
147+
end
148+
149+
describe "#habitat_user_gem_dir" do
150+
it "returns ~/.chef/ruby/<version>/gems path" do
151+
expect(command_instance.send(:habitat_user_gem_dir)).to eq(expected_gem_dir)
152+
end
153+
154+
it "uses the ruby_version from RbConfig" do
155+
allow(RbConfig::CONFIG).to receive(:[]).with("ruby_version").and_return("3.3.0")
156+
expect(command_instance.send(:habitat_user_gem_dir)).to eq(
157+
File.expand_path("~/.chef/ruby/3.3.0/gems")
158+
)
159+
end
160+
end
161+
end

spec/unit/command/shell_init_spec.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,8 @@
346346

347347
context "habitat standalone shell-init on bash" do
348348
let(:cli_hab_path) { "/hab/pkgs/chef/chef-cli/1.0.0/123" }
349+
let(:ruby_version) { RbConfig::CONFIG["ruby_version"] }
350+
let(:user_gem_dir) { File.expand_path("~/.chef/ruby/#{ruby_version}/gems") }
349351

350352
let(:argv) { ["bash"] }
351353

@@ -359,8 +361,8 @@
359361

360362
command_instance.run(argv)
361363
expect(stdout_io.string).to include("export PATH=\"#{cli_hab_path}/bin")
362-
expect(stdout_io.string).to include("export GEM_HOME=\"#{cli_hab_path}/vendor")
363-
expect(stdout_io.string).to include("export GEM_PATH=\"#{cli_hab_path}/vendor")
364+
expect(stdout_io.string).to include("export GEM_HOME=\"#{user_gem_dir}")
365+
expect(stdout_io.string).to include("export GEM_PATH=\"#{user_gem_dir}:#{cli_hab_path}/vendor")
364366
end
365367
end
366368

@@ -380,10 +382,13 @@
380382
expect(command_instance).to receive(:get_pkg_prefix).with("chef/chef-workstation").and_return(chef_dke_path)
381383
expect(command_instance).to receive(:get_pkg_prefix).with("chef/chef-cli").and_return(cli_hab_path)
382384

385+
ruby_version = RbConfig::CONFIG["ruby_version"]
386+
user_gem_dir = File.expand_path("~/.chef/ruby/#{ruby_version}/gems")
387+
383388
command_instance.run(argv)
384389
expect(stdout_io.string).to include("export PATH=\"#{chef_dke_path}/bin")
385-
expect(stdout_io.string).to include("export GEM_HOME=\"#{cli_hab_path}/vendor")
386-
expect(stdout_io.string).to include("export GEM_PATH=\"#{cli_hab_path}/vendor")
390+
expect(stdout_io.string).to include("export GEM_HOME=\"#{user_gem_dir}")
391+
expect(stdout_io.string).to include("export GEM_PATH=\"#{user_gem_dir}:#{cli_hab_path}/vendor")
387392
end
388393

389394
describe "autocompletion" do

0 commit comments

Comments
 (0)