Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

yarn: Forbid zero installs workflow #379

Merged
merged 3 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions cachi2/core/package_managers/yarn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,20 @@ def _resolve_yarn_project(project: Project, output_dir: RootedPath) -> list[Comp

_configure_yarn_version(project)

if project.is_zero_installs:
taylormadore marked this conversation as resolved.
Show resolved Hide resolved
raise PackageRejected(
("Yarn zero install detected, PnP zero installs are unsupported by cachi2"),
solution=(
"Please convert your project to a regular install-based one.\n"
"Depending on whether you use Yarn's PnP or a different node linker Yarn setting "
"make sure to remove '.yarn/cache' or 'node_modules' directories respectively."
),
)

try:
_set_yarnrc_configuration(project, output_dir)
packages = resolve_packages(project.source_dir)

if project.is_zero_installs:
_check_yarn_cache(project.source_dir)
else:
_fetch_dependencies(project.source_dir, output_dir)
_fetch_dependencies(project.source_dir, output_dir)
finally:
_undo_changes(project)

Expand Down Expand Up @@ -97,8 +103,7 @@ def _configure_yarn_version(project: Project) -> None:
def _set_yarnrc_configuration(project: Project, output_dir: RootedPath) -> None:
"""Set all the necessary configuration in yarnrc for the project processing.

:param project: the configuration changes dependending on if the project uses the zero-installs
or the regular workflow.
:param project: a Project instance
:param output_dir: in case the dependencies need to be fetched, this is where they will be
downloaded to.
"""
Expand All @@ -112,13 +117,9 @@ def _set_yarnrc_configuration(project: Project, output_dir: RootedPath) -> None:
yarn_rc.enable_telemetry = False
yarn_rc.ignore_path = True
yarn_rc.unsafe_http_whitelist = []

if project.is_zero_installs:
yarn_rc.enable_immutable_cache = True
else:
yarn_rc.enable_mirror = True
yarn_rc.enable_scripts = False
yarn_rc.global_folder = str(output_dir)
yarn_rc.enable_mirror = True
yarn_rc.enable_scripts = False
yarn_rc.global_folder = str(output_dir)

yarn_rc.write()

Expand Down
35 changes: 25 additions & 10 deletions cachi2/core/package_managers/yarn/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

ChecksumBehavior = Literal["throw", "update", "ignore"]
PnpMode = Literal["strict", "loose"]
NodeLinker = Literal["pnp", "pnpm", "node-modules"]


class Plugin(TypedDict):
Expand Down Expand Up @@ -159,6 +160,15 @@ def unsafe_http_whitelist(self) -> list[str]:
def unsafe_http_whitelist(self, urls: list[str]) -> None:
self._data["unsafeHttpWhitelist"] = urls

@property
def node_linker(self) -> NodeLinker:
"""Get the nodeLinker configuration."""
return self._data.get("nodeLinker", None)

@node_linker.setter
def node_linker(self, node_linker: Optional[NodeLinker]) -> None:
self._data["nodeLinker"] = node_linker

@property
def plugins(self) -> list[Plugin]:
"""Get the configured plugins.
Expand Down Expand Up @@ -291,15 +301,23 @@ class Project(NamedTuple):
def is_zero_installs(self) -> bool:
"""If a project is using the zero-installs workflow or not.

This is determined by the existence of a non-empty yarn cache folder. For more details on
zero-installs, see: https://v3.yarnpkg.com/features/zero-installs.
This is determined either by the existence of a non-empty yarn cache folder
(default PnP mode) or by a presence of an expanded node_modules directory which would work
similarly or exactly the same way as with the NPM ecosystem.
For more details on zero-installs, see: https://v3.yarnpkg.com/features/zero-installs.
"""
dir = self.yarn_cache
node_linker = self.yarn_rc.node_linker
if node_linker is None or node_linker == "pnp":
if self.yarn_cache.path.exists() and self.yarn_cache.path.is_dir():
# in this case the cache folder will be populated with downloaded ZIP dependencies
return any(file.suffix == ".zip" for file in self.yarn_cache.path.iterdir())
eskultety marked this conversation as resolved.
Show resolved Hide resolved

if not dir.path.is_dir():
return False
elif node_linker == "pnpm" or node_linker == "node-modules":
# in this case the cache may or may not be populated with ZIP files because an expanded
# node_modules directory tree just like with NPM is enough for zero installs to work
return self.source_dir.join_within_root("node_modules").path.exists()

return any(file.suffix == ".zip" for file in dir.path.iterdir())
return False

@property
def yarn_cache(self) -> RootedPath:
Expand All @@ -308,10 +326,7 @@ def yarn_cache(self) -> RootedPath:
The cache location is affected by the cacheFolder configuration in yarnrc. See:
https://v3.yarnpkg.com/configuration/yarnrc#cacheFolder.
"""
if self.yarn_rc:
return self.source_dir.join_within_root(self.yarn_rc.cache_folder)

return self.source_dir.join_within_root(DEFAULT_CACHE_FOLDER)
return self.source_dir.join_within_root(self.yarn_rc.cache_folder)

@classmethod
def from_source_dir(cls, source_dir: RootedPath) -> "Project":
Expand Down
37 changes: 21 additions & 16 deletions tests/unit/package_managers/yarn/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from cachi2.core.package_managers.yarn.main import (
_configure_yarn_version,
_fetch_dependencies,
_resolve_yarn_project,
_set_yarnrc_configuration,
)
from cachi2.core.package_managers.yarn.project import YarnRc
Expand Down Expand Up @@ -122,19 +123,27 @@ def test_fetch_dependencies(mock_yarn_cmd: mock.Mock, rooted_tmp_path: RootedPat
assert str(exc_info.value) == "berryscary"


@pytest.mark.parametrize(
"is_zero_installs",
(
pytest.param(True, id="zero-installs-project"),
pytest.param(False, id="regular-workflow-project"),
),
)
@mock.patch("cachi2.core.package_managers.yarn.main._configure_yarn_version")
def test_resolve_zero_installs_fail(
mock_configure_yarn_version: mock.Mock, rooted_tmp_path: RootedPath
) -> None:
mock_configure_yarn_version.return_value = None
project = mock.Mock()
project.is_zero_installs = True
output_dir = rooted_tmp_path.join_within_root("cachi2-output")

with pytest.raises(
PackageRejected,
match=("Yarn zero install detected, PnP zero installs are unsupported by cachi2"),
):
_resolve_yarn_project(project, output_dir)


@mock.patch("cachi2.core.package_managers.yarn.project.YarnRc.write")
def test_set_yarnrc_configuration(mock_write: mock.Mock, is_zero_installs: bool) -> None:
def test_set_yarnrc_configuration(mock_write: mock.Mock) -> None:
yarn_rc = YarnRc(RootedPath("/tmp/.yarnrc.yml"), {})

project = mock.Mock()
project.is_zero_installs = is_zero_installs
project.yarn_rc = yarn_rc

output_dir = RootedPath("/tmp/output")
Expand All @@ -144,20 +153,16 @@ def test_set_yarnrc_configuration(mock_write: mock.Mock, is_zero_installs: bool)
expected_data = {
"checksumBehavior": "throw",
"enableImmutableInstalls": True,
"enableMirror": True,
"enableScripts": False,
"enableStrictSsl": True,
"enableTelemetry": False,
"globalFolder": "/tmp/output",
"ignorePath": True,
"unsafeHttpWhitelist": [],
"pnpMode": "strict",
"plugins": [],
}

if project.is_zero_installs:
expected_data["enableImmutableCache"] = True
else:
expected_data["enableMirror"] = True
expected_data["enableScripts"] = False
expected_data["globalFolder"] = "/tmp/output"

assert yarn_rc._data == expected_data
assert mock_write.called_once()
62 changes: 36 additions & 26 deletions tests/unit/package_managers/yarn/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
enableTelemetry: false
globalFolder: /a/global/folder
ignorePath: true
nodeLinker: pnp
npmRegistryServer: https://registry.alternative.com
npmScopes:
foobar:
Expand Down Expand Up @@ -89,6 +90,7 @@ def test_parse_yarnrc(rooted_tmp_path: RootedPath) -> None:
assert yarn_rc.enable_telemetry is False
assert yarn_rc.global_folder == "/a/global/folder"
assert yarn_rc.ignore_path is True
assert yarn_rc.node_linker == "pnp"
assert yarn_rc.pnp_mode == "loose"
assert yarn_rc.registry_server == "https://registry.alternative.com"
assert yarn_rc.registry_server_for_scope("foobar") == "https://registry.foobar.com"
Expand All @@ -110,6 +112,7 @@ def test_parse_empty_yarnrc(rooted_tmp_path: RootedPath) -> None:
assert yarn_rc.enable_telemetry is None
assert yarn_rc.global_folder is None
assert yarn_rc.ignore_path is None
assert yarn_rc.node_linker is None
assert yarn_rc.pnp_mode is None
assert yarn_rc.registry_server == "https://registry.yarnpkg.com"
assert yarn_rc.registry_server_for_scope("foobar") == "https://registry.yarnpkg.com"
Expand Down Expand Up @@ -187,50 +190,33 @@ def _add_mock_yarn_cache_file(cache_path: RootedPath) -> None:
file.path.touch()


@pytest.mark.parametrize(
"is_zero_installs",
(
pytest.param(True, id="zero-installs-project"),
pytest.param(False, id="regular-workflow-project"),
),
)
def test_parse_project_folder(rooted_tmp_path: RootedPath, is_zero_installs: bool) -> None:
def _setup_zero_installs(nodeLinker: str, rooted_tmp_path: RootedPath) -> None:
Fixed Show fixed Hide fixed
if nodeLinker == "pnp" or nodeLinker == "":
_add_mock_yarn_cache_file(rooted_tmp_path.join_within_root("./.custom/cache"))
else:
rooted_tmp_path.join_within_root("node_modules").path.mkdir()


def test_parse_project_folder(rooted_tmp_path: RootedPath) -> None:
_prepare_package_json_file(rooted_tmp_path, VALID_PACKAGE_JSON_FILE)
_prepare_yarnrc_file(rooted_tmp_path, VALID_YARNRC_FILE)

cache_path = "./.custom/cache"

if is_zero_installs:
_add_mock_yarn_cache_file(rooted_tmp_path.join_within_root(cache_path))

project = Project.from_source_dir(rooted_tmp_path)

assert project.is_zero_installs == is_zero_installs
assert project.yarn_cache == rooted_tmp_path.join_within_root(cache_path)

assert project.yarn_rc is not None
assert project.yarn_rc._path == rooted_tmp_path.join_within_root(".yarnrc.yml")
assert project.package_json._path == rooted_tmp_path.join_within_root("package.json")


@pytest.mark.parametrize(
"is_zero_installs",
(
pytest.param(True, id="zero-installs-project"),
pytest.param(False, id="regular-workflow-project"),
),
)
def test_parse_project_folder_without_yarnrc(
rooted_tmp_path: RootedPath, is_zero_installs: bool
) -> None:
def test_parse_project_folder_without_yarnrc(rooted_tmp_path: RootedPath) -> None:
_prepare_package_json_file(rooted_tmp_path, VALID_PACKAGE_JSON_FILE)

if is_zero_installs:
_add_mock_yarn_cache_file(rooted_tmp_path.join_within_root("./.yarn/cache"))

project = Project.from_source_dir(rooted_tmp_path)

assert project.is_zero_installs == is_zero_installs
assert project.yarn_cache == rooted_tmp_path.join_within_root("./.yarn/cache")

assert project.yarn_rc._data == {}
Expand Down Expand Up @@ -371,3 +357,27 @@ def test_get_semver_from_package_manager(
def test_get_semver_from_package_manager_fail(package_manager: str, expected_error: str) -> None:
with pytest.raises(UnexpectedFormat, match=re.escape(expected_error)):
get_semver_from_package_manager(package_manager)


@pytest.mark.parametrize(
"is_zero_installs, nodeLinker",
[
pytest.param(True, "pnp", id="nodeLinker-pnp"),
pytest.param(True, "pnpm", id="nodeLinker-pnpm"),
pytest.param(True, "node-modules", id="nodeLinker-node-modules"),
pytest.param(True, "", id="nodeLinker-empty-use-default"),
pytest.param(False, "", id="regular-workflow"),
],
)
def test_zero_installs_detection(
rooted_tmp_path: RootedPath, is_zero_installs: bool, nodeLinker: str
) -> None:
yarn_rc = VALID_YARNRC_FILE.replace("nodeLinker: pnp", f"nodeLinker: {nodeLinker}")

_prepare_package_json_file(rooted_tmp_path, VALID_PACKAGE_JSON_FILE)
_prepare_yarnrc_file(rooted_tmp_path, yarn_rc)
project = Project.from_source_dir(rooted_tmp_path)

if is_zero_installs:
_setup_zero_installs(nodeLinker, rooted_tmp_path)
assert project.is_zero_installs is is_zero_installs