diff --git a/craft_application/application.py b/craft_application/application.py index 4e36aabd..4c682240 100644 --- a/craft_application/application.py +++ b/craft_application/application.py @@ -241,6 +241,10 @@ def _configure_services(self, provider_name: str | None) -> None: build_plan=self._build_plan, provider_name=provider_name, ) + self.services.update_kwargs( + "fetch", + build_plan=self._build_plan, + ) def _resolve_project_path(self, project_dir: pathlib.Path | None) -> pathlib.Path: """Find the project file for the current project. diff --git a/craft_application/commands/lifecycle.py b/craft_application/commands/lifecycle.py index fb11c6cf..eb5f3ca6 100644 --- a/craft_application/commands/lifecycle.py +++ b/craft_application/commands/lifecycle.py @@ -398,6 +398,9 @@ def _run( _launch_shell() raise + if parsed_args.use_fetch_service and packages: + self._services.fetch.create_project_manifest(packages) + if not packages: emit.progress("No packages created.", permanent=True) elif len(packages) == 1: diff --git a/craft_application/services/fetch.py b/craft_application/services/fetch.py index 5549b4d2..033782e0 100644 --- a/craft_application/services/fetch.py +++ b/craft_application/services/fetch.py @@ -25,13 +25,19 @@ from craft_cli import emit from typing_extensions import override -from craft_application import fetch, services +from craft_application import fetch, models, services +from craft_application.models.manifest import ProjectManifest if typing.TYPE_CHECKING: from craft_application.application import AppMetadata -class FetchService(services.AppService): +_PROJECT_MANIFEST_MANAGED_PATH = pathlib.Path( + "/tmp/craft-project-manifest.yaml" # noqa: S108 (possibly insecure) +) + + +class FetchService(services.ProjectService): """Service class that handles communication with the fetch-service. This Service is able to spawn a fetch-service instance and create sessions @@ -49,10 +55,18 @@ class FetchService(services.AppService): _fetch_process: subprocess.Popen[str] | None _session_data: fetch.SessionData | None - def __init__(self, app: AppMetadata, services: services.ServiceFactory) -> None: - super().__init__(app, services) + def __init__( + self, + app: AppMetadata, + services: services.ServiceFactory, + *, + project: models.Project, + build_plan: list[models.BuildInfo], + ) -> None: + super().__init__(app, services, project=project) self._fetch_process = None self._session_data = None + self._build_plan = build_plan @override def setup(self) -> None: @@ -103,3 +117,18 @@ def shutdown(self, *, force: bool = False) -> None: """ if force and self._fetch_process: fetch.stop_service(self._fetch_process) + + def create_project_manifest(self, artifacts: list[pathlib.Path]) -> None: + """Create the project manifest for the artifact in ``artifacts``. + + Only supports a single generated artifact, and only in managed runs. + """ + if not self._services.ProviderClass.is_managed(): + emit.debug("Unable to generate the project manifest on the host.") + return + + emit.debug(f"Generating project manifest at {_PROJECT_MANIFEST_MANAGED_PATH}") + project_manifest = ProjectManifest.from_packed_artifact( + self._project, self._build_plan[0], artifacts[0] + ) + project_manifest.to_yaml_file(_PROJECT_MANIFEST_MANAGED_PATH) diff --git a/tests/conftest.py b/tests/conftest.py index db7ed725..9da4a645 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -334,3 +334,8 @@ def _extra_yaml_transform( @pytest.fixture def app(app_metadata, fake_services): return FakeApplication(app_metadata, fake_services) + + +@pytest.fixture +def manifest_data_dir(): + return pathlib.Path(__file__).parent / "data/manifest" diff --git a/tests/unit/models/data/manifest/craft-manifest-expected.json b/tests/data/manifest/craft-manifest-expected.json similarity index 100% rename from tests/unit/models/data/manifest/craft-manifest-expected.json rename to tests/data/manifest/craft-manifest-expected.json diff --git a/tests/unit/models/data/manifest/project-expected.yaml b/tests/data/manifest/project-expected.yaml similarity index 100% rename from tests/unit/models/data/manifest/project-expected.yaml rename to tests/data/manifest/project-expected.yaml diff --git a/tests/unit/models/data/manifest/session-manifest-expected.yaml b/tests/data/manifest/session-manifest-expected.yaml similarity index 100% rename from tests/unit/models/data/manifest/session-manifest-expected.yaml rename to tests/data/manifest/session-manifest-expected.yaml diff --git a/tests/unit/models/data/manifest/session-report.json b/tests/data/manifest/session-report.json similarity index 100% rename from tests/unit/models/data/manifest/session-report.json rename to tests/data/manifest/session-report.json diff --git a/tests/integration/services/test_fetch.py b/tests/integration/services/test_fetch.py index babeee06..f9ee7362 100644 --- a/tests/integration/services/test_fetch.py +++ b/tests/integration/services/test_fetch.py @@ -62,8 +62,10 @@ def _set_test_base_dirs(mocker): @pytest.fixture -def app_service(app_metadata, fake_services): - fetch_service = services.FetchService(app_metadata, fake_services) +def app_service(app_metadata, fake_services, fake_project, fake_build_plan): + fetch_service = services.FetchService( + app_metadata, fake_services, project=fake_project, build_plan=fake_build_plan + ) yield fetch_service fetch_service.shutdown(force=True) diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py index 0c4a1e07..6fa16178 100644 --- a/tests/unit/commands/test_lifecycle.py +++ b/tests/unit/commands/test_lifecycle.py @@ -466,7 +466,9 @@ def test_pack_run( emitter, mock_services, app_metadata, parts, tmp_path, packages, message ): mock_services.package.pack.return_value = packages - parsed_args = argparse.Namespace(parts=parts, output=tmp_path) + parsed_args = argparse.Namespace( + parts=parts, output=tmp_path, use_fetch_service=False + ) command = PackCommand( { "app": app_metadata, @@ -484,6 +486,33 @@ def test_pack_run( emitter.assert_progress(message, permanent=True) +@pytest.mark.parametrize( + ("use_fetch_service", "expect_create_called"), [(True, True), (False, False)] +) +def test_pack_fetch_manifest( + mock_services, app_metadata, tmp_path, use_fetch_service, expect_create_called +): + packages = [pathlib.Path("package.zip")] + mock_services.package.pack.return_value = packages + parsed_args = argparse.Namespace( + output=tmp_path, use_fetch_service=use_fetch_service + ) + command = PackCommand( + { + "app": app_metadata, + "services": mock_services, + } + ) + + command.run(parsed_args) + + mock_services.package.pack.assert_called_once_with( + mock_services.lifecycle.prime_dir, + tmp_path, + ) + assert mock_services.fetch.create_project_manifest.called == expect_create_called + + def test_pack_run_wrong_step(app_metadata, fake_services): parsed_args = argparse.Namespace(parts=None, output=pathlib.Path()) command = PackCommand( @@ -595,7 +624,9 @@ def test_shell_after_pack( mocker, mock_subprocess_run, ): - parsed_args = argparse.Namespace(shell_after=True, output=pathlib.Path()) + parsed_args = argparse.Namespace( + shell_after=True, output=pathlib.Path(), use_fetch_service=False + ) mock_lifecycle_run = mocker.patch.object(fake_services.lifecycle, "run") mock_pack = mocker.patch.object(fake_services.package, "pack") mocker.patch.object( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 9ad49687..33749dd3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -50,4 +50,5 @@ def mock_services(app_metadata, fake_project, fake_package_service_class): factory.package = mock.Mock(spec=services.PackageService) factory.provider = mock.Mock(spec=services.ProviderService) factory.remote_build = mock.Mock(spec_set=services.RemoteBuildService) + factory.fetch = mock.Mock(spec=services.FetchService) return factory diff --git a/tests/unit/models/test_manifest.py b/tests/unit/models/test_manifest.py index 2da53122..2bd3b246 100644 --- a/tests/unit/models/test_manifest.py +++ b/tests/unit/models/test_manifest.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . import json -import pathlib from datetime import datetime import pytest @@ -28,11 +27,8 @@ from craft_providers import bases from freezegun import freeze_time -TEST_DATA_DIR = pathlib.Path(__file__).parent / "data/manifest" - @pytest.fixture -# @freeze_time("2024-09-16T01:02:03.456789") @freeze_time(datetime.fromisoformat("2024-09-16T01:02:03.456789")) def project_manifest(tmp_path, fake_project): project = fake_project @@ -50,27 +46,29 @@ def project_manifest(tmp_path, fake_project): @pytest.fixture -def session_report(): - report_path = TEST_DATA_DIR / "session-report.json" +def session_report(manifest_data_dir): + report_path = manifest_data_dir / "session-report.json" return json.loads(report_path.read_text()) -def test_from_packed_artifact(project_manifest): - expected = (TEST_DATA_DIR / "project-expected.yaml").read_text() +def test_from_packed_artifact(project_manifest, manifest_data_dir): + expected = (manifest_data_dir / "project-expected.yaml").read_text() obtained = project_manifest.to_yaml_string() assert obtained == expected -def test_from_session_report(session_report): +def test_from_session_report(session_report, manifest_data_dir): deps = SessionArtifactManifest.from_session_report(session_report) obtained = util.dump_yaml([d.marshal() for d in deps]) - expected = (TEST_DATA_DIR / "session-manifest-expected.yaml").read_text() + expected = (manifest_data_dir / "session-manifest-expected.yaml").read_text() assert obtained == expected -def test_create_craft_manifest(tmp_path, project_manifest, session_report): +def test_create_craft_manifest( + tmp_path, project_manifest, session_report, manifest_data_dir +): project_manifest_path = tmp_path / "project-manifest.yaml" project_manifest.to_yaml_file(project_manifest_path) @@ -79,6 +77,6 @@ def test_create_craft_manifest(tmp_path, project_manifest, session_report): ) obtained = json.dumps(craft_manifest.marshal(), indent=2) + "\n" - expected = (TEST_DATA_DIR / "craft-manifest-expected.json").read_text() + expected = (manifest_data_dir / "craft-manifest-expected.json").read_text() assert obtained == expected diff --git a/tests/unit/services/test_fetch.py b/tests/unit/services/test_fetch.py index e5cf0ddb..ebcc5832 100644 --- a/tests/unit/services/test_fetch.py +++ b/tests/unit/services/test_fetch.py @@ -23,15 +23,28 @@ the FetchService class. """ import re +from datetime import datetime from unittest.mock import MagicMock import pytest from craft_application import fetch, services +from craft_application.models import BuildInfo +from craft_application.services import fetch as service_module +from craft_providers import bases +from freezegun import freeze_time @pytest.fixture -def fetch_service(app, fake_services): - return services.FetchService(app, fake_services) +def fetch_service(app, fake_services, fake_project): + build_info = BuildInfo( + platform="amd64", + build_on="amd64", + build_for="amd64", + base=bases.BaseName("ubuntu", "24.04"), + ) + return services.FetchService( + app, fake_services, project=fake_project, build_plan=[build_info] + ) def test_create_session_already_exists(fetch_service): @@ -51,3 +64,36 @@ def test_teardown_session_no_session(fetch_service): with pytest.raises(ValueError, match=expected): fetch_service.teardown_session() + + +@freeze_time(datetime.fromisoformat("2024-09-16T01:02:03.456789")) +def test_create_project_manifest( + fetch_service, tmp_path, monkeypatch, manifest_data_dir +): + manifest_path = tmp_path / "craft-project-manifest.yaml" + monkeypatch.setattr(service_module, "_PROJECT_MANIFEST_MANAGED_PATH", manifest_path) + monkeypatch.setenv("CRAFT_MANAGED_MODE", "1") + + artifact = tmp_path / "my-artifact.file" + artifact.write_text("this is the generated artifact") + + assert not manifest_path.exists() + fetch_service.create_project_manifest([artifact]) + + assert manifest_path.is_file() + expected = manifest_data_dir / "project-expected.yaml" + + assert manifest_path.read_text() == expected.read_text() + + +def test_create_project_manifest_not_managed(fetch_service, tmp_path, monkeypatch): + manifest_path = tmp_path / "craft-project-manifest.yaml" + monkeypatch.setattr(service_module, "_PROJECT_MANIFEST_MANAGED_PATH", manifest_path) + monkeypatch.setenv("CRAFT_MANAGED_MODE", "0") + + artifact = tmp_path / "my-artifact.file" + artifact.write_text("this is the generated artifact") + + assert not manifest_path.exists() + fetch_service.create_project_manifest([artifact]) + assert not manifest_path.exists()