Skip to content

Commit 62456d8

Browse files
committed
feat(bundler): support git gems
Signed-off-by: Roman Blanco <rblanco@redhat.com>
1 parent b04ef85 commit 62456d8

4 files changed

Lines changed: 114 additions & 46 deletions

File tree

hermeto/core/package_managers/bundler/main.py

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@
2828
log = logging.getLogger(__name__)
2929

3030
CONFIG_OVERRIDE = "bundler/config_override"
31+
GIT_CONFIG_OVERRIDE = "bundler/git_config"
3132

3233

3334
def fetch_bundler_source(request: Request) -> RequestOutput:
3435
"""Resolve and process all bundler packages."""
3536
components: list[Component] = []
36-
environment_variables: list[EnvironmentVariable] = (
37-
_prepare_environment_variables_for_hermetic_build()
38-
)
3937
project_files: list[ProjectFile] = []
4038
git_paths = []
4139

@@ -48,7 +46,11 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
4846
)
4947
components.extend(_comps)
5048
git_paths.extend(_git_paths)
51-
project_files.append(
49+
50+
environment_variables: list[EnvironmentVariable] = (
51+
_prepare_environment_variables_for_hermetic_build(has_git_deps=bool(git_paths))
52+
)
53+
project_files.extend(
5254
_prepare_for_hermetic_build(request.source_dir, request.output_dir, git_paths)
5355
)
5456

@@ -63,17 +65,17 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
6365
)
6466

6567

66-
# Aliases for git dependency name and git dependency name as
67-
# it is written to file system:
68+
# Aliases for git dependency name, file system name, and remote URL:
6869
DepName = str
6970
FSDepName = str
71+
DepURL = str
7072

7173

7274
def _resolve_bundler_package(
7375
package_dir: RootedPath,
7476
output_dir: RootedPath,
7577
binary_filters: BundlerBinaryFilters | None = None,
76-
) -> tuple[list[Component], list[tuple[DepName, FSDepName]]]:
78+
) -> tuple[list[Component], list[tuple[DepName, FSDepName, DepURL]]]:
7779
"""Process a request for a single bundler package."""
7880
deps_dir = output_dir.join_within_root("deps", "bundler")
7981
deps_dir.path.mkdir(parents=True, exist_ok=True)
@@ -104,7 +106,7 @@ def _resolve_bundler_package(
104106
else:
105107
properties = []
106108
if isinstance(dep, GitDependency):
107-
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12]))
109+
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12], str(dep.url)))
108110

109111
c = Component(name=dep.name, version=dep.version, purl=dep.purl, properties=properties)
110112
components.append(c)
@@ -183,16 +185,28 @@ def _get_repo_name_from_origin_remote(package_dir: RootedPath) -> str:
183185
return str(resolved_path)
184186

185187

186-
def _prepare_environment_variables_for_hermetic_build() -> list[EnvironmentVariable]:
187-
return [
188+
def _prepare_environment_variables_for_hermetic_build(
189+
has_git_deps: bool = False,
190+
) -> list[EnvironmentVariable]:
191+
env_vars = [
188192
# Contains path to a directory where a new config could be found.
189193
EnvironmentVariable(name="BUNDLE_APP_CONFIG", value="${output_dir}/" + CONFIG_OVERRIDE),
190194
]
195+
if has_git_deps:
196+
# Redirect git remote URLs to pre-fetched local clones so that bundler
197+
# installs git gems into BUNDLE_PATH (not in-place from the clone).
198+
# See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf
199+
env_vars.append(
200+
EnvironmentVariable(
201+
name="GIT_CONFIG_GLOBAL", value="${output_dir}/" + GIT_CONFIG_OVERRIDE
202+
)
203+
)
204+
return env_vars
191205

192206

193207
def _prepare_for_hermetic_build(
194208
source_dir: RootedPath, output_dir: RootedPath, git_paths: list | None = None
195-
) -> ProjectFile:
209+
) -> list[ProjectFile]:
196210
"""Prepare a package for hermetic build by injecting necessary config."""
197211
potential_bundle_config = source_dir.join_within_root(".bundle/config").path
198212
hermetic_config = dedent(
@@ -205,30 +219,6 @@ def _prepare_for_hermetic_build(
205219
BUNDLE_VERSION: "system"
206220
"""
207221
)
208-
# Note: if a package depends on a git revision then the following variables
209-
# are necessary for a hermetic build:
210-
# BUNDLE_DISABLE_LOCAL_BRANCH_CHECK
211-
# BUNDLE_DISABLE_LOCAL_REVISION_CHECK
212-
# because otherwise some (potentially all, depending on exact set of
213-
# ecosystem components versions, environment variables and celestial
214-
# alignment) Bundler versions will try to fetch the latest changes of the
215-
# remotes which may be present even when instructed not to with --local
216-
# flag.
217-
# See https://bundler.io/guides/git.html#local-git-repos for details.
218-
# (or https://github.com/rubygems/bundler-site/blob/
219-
# 9ff3b76e9866524ecefe165633ffb547f0004a99/source/guides/git.html.md
220-
# if the link above ceases to exist).
221-
if git_paths is not None:
222-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"\n'
223-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"\n'
224-
for packname, dirname in git_paths:
225-
# "-" in variable names is deprecated in Bundler and now generates
226-
# a warning and a suggestion to replace all dashes with triple
227-
# underscores. Package names sometimes contain dashes:
228-
varname = "BUNDLE_LOCAL." + packname.upper().replace("-", "___")
229-
location = "${output_dir}/deps/bundler/" + dirname
230-
config_entry = varname + f': "{location}"'
231-
hermetic_config += f"{config_entry}\n"
232222
if potential_bundle_config.is_file():
233223
config_data = potential_bundle_config.read_text()
234224
config_data += hermetic_config
@@ -241,4 +231,35 @@ def _prepare_for_hermetic_build(
241231
else:
242232
config_data = hermetic_config
243233
overriding_bundler_config_path = output_dir.join_within_root(CONFIG_OVERRIDE, "config").path
244-
return ProjectFile(abspath=overriding_bundler_config_path, template=config_data)
234+
project_files = [ProjectFile(abspath=overriding_bundler_config_path, template=config_data)]
235+
236+
if git_paths:
237+
# Redirect each git remote URL to its pre-fetched local clone via a git
238+
# url.insteadOf rewrite. This causes bundler to clone from the local
239+
# filesystem (no network) and then check out the gem into BUNDLE_PATH,
240+
# so the gem is present in the runtime image after a multi-stage build.
241+
#
242+
# Using BUNDLE_LOCAL.* instead would make bundler call set_install_path!
243+
# with the clone directory, causing it to use the gem in-place and never
244+
# copy it into BUNDLE_PATH — leaving the gem absent from the runtime image.
245+
url_to_dirs: dict[str, set[str]] = {}
246+
for _packname, dirname, url in git_paths:
247+
url_to_dirs.setdefault(url, set()).add(dirname)
248+
for url, dirs in url_to_dirs.items():
249+
if len(dirs) > 1:
250+
raise UnsupportedFeature(
251+
f"Multiple git dependencies point to the same repository ({url}) "
252+
f"but use different revisions: {', '.join(sorted(dirs))}. "
253+
"This is not supported because git's url.insteadOf redirect "
254+
"can only map a repository URL to a single local clone."
255+
)
256+
git_config_lines = []
257+
for _packname, dirname, url in git_paths:
258+
clone_file_url = "file://${output_dir}/deps/bundler/" + dirname + "/"
259+
git_config_lines.append(f'[url "{clone_file_url}"]\n\tinsteadOf = {url}\n')
260+
git_config_path = output_dir.join_within_root(GIT_CONFIG_OVERRIDE).path
261+
project_files.append(
262+
ProjectFile(abspath=git_config_path, template="".join(git_config_lines))
263+
)
264+
265+
return project_files
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
environment_variables:
22
- name: BUNDLE_APP_CONFIG
33
value: ${output_dir}/bundler/config_override
4+
- name: GIT_CONFIG_GLOBAL
5+
value: ${output_dir}/bundler/git_config
46
project_files:
57
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/config_override/config
68
template: |2
@@ -11,6 +13,7 @@ project_files:
1113
BUNDLE_ALLOW_OFFLINE_INSTALL: "true"
1214
BUNDLE_DISABLE_VERSION_CHECK: "true"
1315
BUNDLE_VERSION: "system"
14-
BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"
15-
BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"
16-
BUNDLE_LOCAL.JSON___SCHEMA: "${output_dir}/deps/bundler/json-schema-26487618a684"
16+
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/git_config
17+
template: |
18+
[url "file://${output_dir}/deps/bundler/json-schema-26487618a684/"]
19+
insteadOf = https://github.com/3scale/json-schema
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
environment_variables:
22
- name: BUNDLE_APP_CONFIG
33
value: ${output_dir}/bundler/config_override
4+
- name: GIT_CONFIG_GLOBAL
5+
value: ${output_dir}/bundler/git_config
46
project_files:
57
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/config_override/config
68
template: |2
@@ -11,6 +13,7 @@ project_files:
1113
BUNDLE_ALLOW_OFFLINE_INSTALL: "true"
1214
BUNDLE_DISABLE_VERSION_CHECK: "true"
1315
BUNDLE_VERSION: "system"
14-
BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"
15-
BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"
16-
BUNDLE_LOCAL.JSON___SCHEMA: "${output_dir}/deps/bundler/json-schema-26487618a684"
16+
- abspath: ${test_case_tmp_path}/hermeto-output/bundler/git_config
17+
template: |
18+
[url "file://${output_dir}/deps/bundler/json-schema-26487618a684/"]
19+
insteadOf = https://github.com/3scale/json-schema

tests/unit/package_managers/bundler/test_main.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from git.repo import Repo
77

88
from hermeto.core.constants import Mode
9-
from hermeto.core.errors import NotAGitRepo, PackageRejected
9+
from hermeto.core.errors import NotAGitRepo, PackageRejected, UnsupportedFeature
1010
from hermeto.core.package_managers.bundler.main import (
1111
_get_main_package_name_and_version,
1212
_get_repo_name_from_origin_remote,
@@ -83,7 +83,8 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_empty_config
8383

8484
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
8585

86-
assert result.template == expected_config_contents
86+
assert len(result) == 1
87+
assert result[0].template == expected_config_contents
8788

8889

8990
def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_config(
@@ -115,7 +116,8 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_con
115116

116117
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
117118

118-
assert result.template == existing_preamble + expected_config_contents
119+
assert len(result) == 1
120+
assert result[0].template == existing_preamble + expected_config_contents
119121

120122

121123
def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_alternate_config(
@@ -152,7 +154,46 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_alt
152154
ge.return_value = str(expected_alternate_config_location.parent)
153155
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
154156

155-
assert result.template == existing_preamble + expected_alternate_config_contents
157+
assert len(result) == 1
158+
assert result[0].template == existing_preamble + expected_alternate_config_contents
159+
160+
161+
def test_prepare_for_hermetic_build_generates_git_config_for_git_deps(
162+
rooted_tmp_path: RootedPath,
163+
) -> None:
164+
git_paths = [
165+
(
166+
"yabeda-activejob",
167+
"yabeda-activejob-1894d97a925d",
168+
"https://github.com/org/yabeda-activejob.git",
169+
),
170+
("my-gem", "my-gem-abc123def456", "https://github.com/org/my-gem.git"),
171+
]
172+
173+
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path, git_paths=git_paths)
174+
175+
assert len(result) == 2
176+
expected_git_config = dedent(
177+
"""\
178+
[url "file://${output_dir}/deps/bundler/yabeda-activejob-1894d97a925d/"]
179+
\tinsteadOf = https://github.com/org/yabeda-activejob.git
180+
[url "file://${output_dir}/deps/bundler/my-gem-abc123def456/"]
181+
\tinsteadOf = https://github.com/org/my-gem.git
182+
"""
183+
)
184+
assert result[1].template == expected_git_config
185+
186+
187+
def test_prepare_for_hermetic_build_raises_on_duplicate_git_url(
188+
rooted_tmp_path: RootedPath,
189+
) -> None:
190+
git_paths = [
191+
("gem-a", "gem-a-aabbccdd1234", "https://github.com/org/monorepo.git"),
192+
("gem-b", "gem-b-eeff00112233", "https://github.com/org/monorepo.git"),
193+
]
194+
195+
with pytest.raises(UnsupportedFeature, match="same repository"):
196+
_prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path, git_paths=git_paths)
156197

157198

158199
@mock.patch("hermeto.core.package_managers.bundler.main.get_repo_id")

0 commit comments

Comments
 (0)