Skip to content

Commit

Permalink
feat: generate project manifest in managed runs
Browse files Browse the repository at this point in the history
Add a create_project_manifest() method to the FetchService that, when running
in managed mode, creates the yaml for the project metadata in a path that will
be later accessed by the host-side service to create the "full" craft manifest.
  • Loading branch information
tigarmo committed Sep 20, 2024
1 parent 575ac20 commit e258642
Show file tree
Hide file tree
Showing 13 changed files with 141 additions and 22 deletions.
4 changes: 4 additions & 0 deletions craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions craft_application/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 33 additions & 4 deletions craft_application/services/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
File renamed without changes.
File renamed without changes.
6 changes: 4 additions & 2 deletions tests/integration/services/test_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
35 changes: 33 additions & 2 deletions tests/unit/commands/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 10 additions & 12 deletions tests/unit/models/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import pathlib
from datetime import datetime

import pytest
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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
50 changes: 48 additions & 2 deletions tests/unit/services/test_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()

0 comments on commit e258642

Please sign in to comment.