diff --git a/snapcraft/parts/setup_assets.py b/snapcraft/parts/setup_assets.py index b5d6429ee1..198b5682e9 100644 --- a/snapcraft/parts/setup_assets.py +++ b/snapcraft/parts/setup_assets.py @@ -32,6 +32,13 @@ from .desktop_file import DesktopFile +def _uses_legacy_system_metadata(project: models.Project) -> bool: + """Return whether gadget/kernel metadata should follow the core22 path.""" + return project.base == "core22" or ( + project.base is None and project.build_base == "core22" + ) + + def setup_assets( project: models.Project, *, @@ -67,16 +74,16 @@ def setup_assets( ) setup_hooks(component.hooks, prime_dirs[component_name]) - if project.type == const.ProjectType.GADGET: - gadget_yaml = project_dir / "gadget.yaml" - if not gadget_yaml.exists(): - raise errors.SnapcraftError("gadget.yaml is required for gadget snaps") - _copy_file(gadget_yaml, meta_dir / "gadget.yaml") - - if project.type == const.ProjectType.KERNEL: - kernel_yaml = project_dir / "kernel.yaml" - if kernel_yaml.exists(): - _copy_file(kernel_yaml, meta_dir / "kernel.yaml") + if _uses_legacy_system_metadata(project): + if project.type == const.ProjectType.GADGET: + gadget_yaml = project_dir / "gadget.yaml" + if not gadget_yaml.exists(): + raise errors.SnapcraftError("gadget.yaml is required for gadget snaps") + _copy_file(gadget_yaml, meta_dir / "gadget.yaml") + elif project.type == const.ProjectType.KERNEL: + kernel_yaml = project_dir / "kernel.yaml" + if kernel_yaml.exists(): + _copy_file(kernel_yaml, meta_dir / "kernel.yaml") icon_path = _finalize_icon( project.icon, assets_dir=assets_dir, gui_dir=gui_dir, prime_dir=prime_dir diff --git a/snapcraft/services/package.py b/snapcraft/services/package.py index 40e3f0b818..25b044df43 100644 --- a/snapcraft/services/package.py +++ b/snapcraft/services/package.py @@ -29,7 +29,7 @@ from craft_cli import emit from typing_extensions import override -from snapcraft import errors, linters, models, pack +from snapcraft import const, errors, linters, models, pack from snapcraft.errors import SnapcraftPrecreationEscapesPrimeError from snapcraft.linters import LinterStatus from snapcraft.meta import component_yaml, snap_yaml @@ -68,6 +68,40 @@ def _get_component_yaml(self, partition: str | None = None) -> str: component_name = partition.removeprefix("component/") return component_yaml.get_str(self._project, component_name) + @package_file("meta/gadget.yaml", partition_re="default") + def _get_gadget_yaml( + self, partition: str | None = None # noqa: ARG002 + ) -> str | Literal[False] | None: + """Generate mediated gadget metadata for core24+ gadget snaps. + + Returns ``False`` (leave existing file untouched) when this project + should not be mediated (core22 snaps use the legacy copy path in + ``setup_assets``) or when the project is not a gadget snap. + """ + if self._project.type != const.ProjectType.GADGET: + return False + + return self._read_project_metadata_file( + "gadget.yaml", + required=True, + error_message="gadget.yaml is required for gadget snaps", + ) + + @package_file("meta/kernel.yaml", partition_re="default") + def _get_kernel_yaml( + self, partition: str | None = None # noqa: ARG002 + ) -> str | Literal[False] | None: + """Generate mediated kernel metadata for core24+ kernel snaps. + + Returns ``False`` (leave existing file untouched) when this project + should not be mediated (core22 snaps use the legacy copy path in + ``setup_assets``) or when the project is not a kernel snap. + """ + if self._project.type != const.ProjectType.KERNEL: + return False + + return self._read_project_metadata_file("kernel.yaml") + @override def setup(self) -> None: """Application-specific service setup.""" @@ -433,6 +467,37 @@ def _get_assets_dir(self) -> pathlib.Path: # This is for backwards compatibility with setup_assets(...) return project_dir / "snap" + def _read_project_metadata_file( + self, + filename: str, + *, + required: bool = False, + error_message: str | None = None, + ) -> str | None: + """Read a top-level project metadata file if it exists.""" + metadata_path = self._services.lifecycle.project_info.project_dir / filename + if metadata_path.exists(): + return metadata_path.read_text(encoding="utf-8") + + if required: + raise errors.SnapcraftError(error_message or f"{filename} is required") + + return None + + def _write_system_metadata(self, path: pathlib.Path) -> None: + """Materialize mediated gadget/kernel metadata files for core24+ snaps.""" + meta_dir = path / "meta" + + if self._project.type == const.ProjectType.GADGET: + contents = self._get_gadget_yaml() + if isinstance(contents, str): + (meta_dir / "gadget.yaml").write_text(contents, encoding="utf-8") + + if self._project.type == const.ProjectType.KERNEL: + contents = self._get_kernel_yaml() + if isinstance(contents, str): + (meta_dir / "kernel.yaml").write_text(contents, encoding="utf-8") + @override def write_metadata(self, path: pathlib.Path) -> None: """Write the project metadata to metadata.yaml in the given directory. @@ -466,6 +531,7 @@ def write_metadata(self, path: pathlib.Path) -> None: prime_dirs=lifecycle_service.prime_dirs, meta_directory_handler=meta_directory_handler, ) + self._write_system_metadata(path) for component in self._project.get_component_names(): component_yaml.write( diff --git a/tests/unit/parts/test_setup_assets.py b/tests/unit/parts/test_setup_assets.py index 5e7be689c0..540303dc94 100644 --- a/tests/unit/parts/test_setup_assets.py +++ b/tests/unit/parts/test_setup_assets.py @@ -111,6 +111,30 @@ def test_gadget(yaml_data, gadget_yaml_file, new_dir): assert gadget_path.is_file() +def test_gadget_core24_not_copied(yaml_data, gadget_yaml_file, new_dir): + project = models.Project.unmarshal( + yaml_data( + { + "type": "gadget", + "base": "core24", + "build-base": "core24", + "version": "1.0", + "summary": "summary", + "description": "description", + } + ) + ) + + setup_assets( + project, + assets_dir=Path("snap"), + project_dir=Path.cwd(), + prime_dirs={None: Path("prime")}, + ) + + assert not Path("prime/meta/gadget.yaml").exists() + + def test_gadget_missing(yaml_data, new_dir): project = models.Project.unmarshal( yaml_data( @@ -144,7 +168,7 @@ def test_kernel(yaml_data, kernel_yaml_file, new_dir): "summary": "summary", "description": "description", "parts": {}, - "build-base": "devel", + "build-base": "core22", } ) @@ -160,6 +184,30 @@ def test_kernel(yaml_data, kernel_yaml_file, new_dir): assert kernel_path.is_file() +def test_kernel_core24_not_copied(yaml_data, kernel_yaml_file, new_dir): + project = models.Project.unmarshal( + { + "name": "custom-kernel", + "type": "kernel", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "parts": {}, + "build-base": "core24", + } + ) + + setup_assets( + project, + assets_dir=Path("snap"), + project_dir=Path.cwd(), + prime_dirs={None: Path("prime")}, + ) + + assert not Path("prime/meta/kernel.yaml").exists() + + def test_kernel_missing(yaml_data, new_dir): project = models.Project.unmarshal( { @@ -170,7 +218,7 @@ def test_kernel_missing(yaml_data, new_dir): "summary": "summary", "description": "description", "parts": {}, - "build-base": "devel", + "build-base": "core22", } ) diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index 89a414ef92..2e64264a9e 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -30,7 +30,7 @@ from pytest_mock import MockerFixture from snapcraft import __version__, linters, meta, models, pack -from snapcraft.errors import SnapcraftPrecreationEscapesPrimeError +from snapcraft.errors import SnapcraftError, SnapcraftPrecreationEscapesPrimeError from snapcraft.meta import ExtractedMetadata from snapcraft.parts import extract_metadata, update_metadata from snapcraft.services import Package @@ -153,6 +153,103 @@ def test_get_snap_yaml(default_project, fake_services, setup_project): ) +def test_get_gadget_yaml(fake_services, setup_project): + project = { + "name": "pc", + "type": "gadget", + "base": "core24", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + project_dir = package_service._services.lifecycle.project_info.project_dir + (project_dir / "gadget.yaml").write_text("volumes: {}\n", encoding="utf-8") + + assert package_service._get_gadget_yaml() == "volumes: {}\n" + + +def test_get_gadget_yaml_missing_raises(fake_services, setup_project): + project = { + "name": "pc", + "type": "gadget", + "base": "core24", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + + with pytest.raises(SnapcraftError, match="gadget.yaml is required for gadget snaps"): + package_service._get_gadget_yaml() + + +def test_get_kernel_yaml(fake_services, setup_project): + project = { + "name": "custom-kernel", + "type": "kernel", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + "build-base": "core24", + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + project_dir = package_service._services.lifecycle.project_info.project_dir + (project_dir / "kernel.yaml").write_text("kernel-key: kernel-value\n", encoding="utf-8") + + assert package_service._get_kernel_yaml() == "kernel-key: kernel-value\n" + + +def test_get_kernel_yaml_missing_returns_none(fake_services, setup_project): + project = { + "name": "custom-kernel", + "type": "kernel", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + "build-base": "core24", + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + + assert package_service._get_kernel_yaml() is None + + +def test_get_gadget_yaml_non_gadget_returns_false( + default_project, fake_services, setup_project +): + """Non-gadget projects should not touch meta/gadget.yaml via mediation.""" + setup_project(fake_services, default_project.marshal()) + package_service = cast(Package, fake_services.get("package")) + + assert package_service._get_gadget_yaml() is False + + +def test_get_kernel_yaml_non_kernel_returns_false( + default_project, fake_services, setup_project +): + """Non-kernel projects should not touch meta/kernel.yaml via mediation.""" + setup_project(fake_services, default_project.marshal()) + package_service = cast(Package, fake_services.get("package")) + + assert package_service._get_kernel_yaml() is False + + def test_get_artifacts(default_project, fake_services, setup_project, tmp_path): setup_project(fake_services, default_project.marshal()) package_service = fake_services.get("package") @@ -222,6 +319,75 @@ def test_write_metadata_with_manifest( assert manifest.architectures == snap_yaml["architectures"] +def test_write_metadata_writes_gadget_yaml(fake_services, setup_project, tmp_path): + project = { + "name": "pc", + "type": "gadget", + "base": "core24", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + project_dir = package_service._services.lifecycle.project_info.project_dir + (project_dir / "gadget.yaml").write_text("volumes: {}\n", encoding="utf-8") + + prime_dir = tmp_path / "prime" + package_service.write_metadata(prime_dir) + + assert (prime_dir / "meta" / "gadget.yaml").read_text(encoding="utf-8") == "volumes: {}\n" + + +def test_write_metadata_writes_kernel_yaml(fake_services, setup_project, tmp_path): + project = { + "name": "custom-kernel", + "type": "kernel", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + "build-base": "core24", + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + project_dir = package_service._services.lifecycle.project_info.project_dir + (project_dir / "kernel.yaml").write_text("kernel-key: kernel-value\n", encoding="utf-8") + + prime_dir = tmp_path / "prime" + package_service.write_metadata(prime_dir) + + assert (prime_dir / "meta" / "kernel.yaml").read_text(encoding="utf-8") == "kernel-key: kernel-value\n" + + +def test_write_metadata_missing_kernel_yaml_leaves_no_file( + fake_services, setup_project, tmp_path +): + project = { + "name": "custom-kernel", + "type": "kernel", + "confinement": "strict", + "version": "1.0", + "summary": "summary", + "description": "description", + "grade": "stable", + "parts": {}, + "build-base": "core24", + } + setup_project(fake_services, project) + package_service = cast(Package, fake_services.get("package")) + + prime_dir = tmp_path / "prime" + package_service.write_metadata(prime_dir) + + assert not (prime_dir / "meta" / "kernel.yaml").exists() + + @pytest.fixture(params=["snap", "build-aux/snap"]) def project_hooks_dir(in_project_path, request): hooks_dir = in_project_path / request.param / "hooks" @@ -404,43 +570,13 @@ def test_extra_project_updates_makes_targets_core26( mock_precreate_layout = mocker.patch.object( package_service, "_precreate_layout_targets" ) - mock_precreate_plugs = mocker.patch.object( + mocker.patch.object( package_service, "_precreate_plug_targets" ) package_service.update_project() mock_precreate_layout.assert_called_once() - mock_precreate_plugs.assert_called_once() - - -@pytest.mark.parametrize( - "base", - [ - "core22", - "core24", - ], -) -def test_extra_project_updates_no_make_targets_legacy( - snapcraft_yaml: Callable[..., Any], - setup_project: Callable[..., Any], - fake_services: ServiceFactory, - mocker: MockerFixture, - base: str, -) -> None: - setup_project(fake_services, snapcraft_yaml(base=base)) - package_service = fake_services.get("package") - mock_precreate_layout = mocker.patch.object( - package_service, "_precreate_layout_targets" - ) - mock_precreate_plugs = mocker.patch.object( - package_service, "_precreate_plug_targets" - ) - - package_service.update_project() - - mock_precreate_layout.assert_not_called() - mock_precreate_plugs.assert_not_called() @pytest.mark.parametrize(