diff --git a/docs/requirements.txt b/docs/requirements.txt index 04a7cde9cc..2934552d82 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ attrs==23.1.0 catkin-pkg==0.5.2 click==8.1.7 -craft-application==2.3.0 +craft-application==2.4.0 craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.1 diff --git a/requirements-devel.txt b/requirements-devel.txt index a2b0559177..a8eb933313 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.7 codespell==2.2.6 colorama==0.4.6 coverage==7.4.4 -craft-application==2.3.0 +craft-application==2.4.0 craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/requirements.txt b/requirements.txt index 349394f9a7..71841f0991 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.16.0 chardet==5.2.0 charset-normalizer==3.3.2 click==8.1.7 -craft-application==2.3.0 +craft-application==2.4.0 craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/snapcraft/commands/remote.py b/snapcraft/commands/remote.py index 0bc0157fe4..52196eee41 100644 --- a/snapcraft/commands/remote.py +++ b/snapcraft/commands/remote.py @@ -25,6 +25,7 @@ from typing import Any, cast import lazr.restfulclient.errors +from craft_application import errors from craft_application.application import filter_plan from craft_application.commands import ExtensibleCommand from craft_application.errors import RemoteBuildError @@ -113,6 +114,38 @@ def _fill_parser(self, parser: argparse.ArgumentParser) -> None: default=os.getenv("CRAFT_BUILD_FOR"), help="Set architecture to build for", ) + parser.add_argument( + "--project", help="upload to the specified Launchpad project" + ) + + def _validate(self, parsed_args: argparse.Namespace) -> None: + """Do pre-build validation.""" + if os.getenv("SUDO_USER") and os.geteuid() == 0: + emit.progress( + "Running with 'sudo' may cause permission errors and is discouraged.", + permanent=True, + ) + # Give the user a bit of time to process this before proceeding. + time.sleep(1) + + if ( + not parsed_args.launchpad_accept_public_upload + and ( + not parsed_args.project + or not self._services.remote_build.is_project_private() + ) + and not confirm_with_user(_CONFIRMATION_PROMPT, default=False) + ): + raise errors.RemoteBuildError( + "Remote build needs explicit acknowledgement that data sent to build servers " + "is public.", + details=( + "In non-interactive runs, please use the option " + "`--launchpad-accept-public-upload`." + ), + reportable=False, + retcode=77, + ) # pylint: disable=too-many-statements def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: @@ -122,31 +155,15 @@ def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: :raises AcceptPublicUploadError: If the user does not agree to upload data. """ - if os.getenv("SUDO_USER") and os.geteuid() == 0: - emit.progress( - "Running with 'sudo' may cause permission errors and is discouraged.", - permanent=True, - ) - # Give the user a bit of time to process this before proceeding. - time.sleep(1) + if parsed_args.project: + self._services.remote_build.set_project(parsed_args.project) + self._validate(parsed_args) emit.progress( "remote-build is experimental and is subject to change. Use with caution.", permanent=True, ) - if not parsed_args.launchpad_accept_public_upload and not confirm_with_user( - _CONFIRMATION_PROMPT, default=False - ): - emit.progress( - "Remote build needs explicit acknowledgement that data sent to build servers is " - "public.\n" - "In non-interactive runs, please use the option " - "`--launchpad-accept-public-upload`.", - permanent=True, - ) - return 77 - builder = self._services.remote_build project = cast(models.Project, self._services.project) config = cast(dict[str, Any], self.config) @@ -158,10 +175,8 @@ def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: emit.trace(f"Project directory: {project_dir}") - build_planner = self._app.BuildPlannerClass.unmarshal(project.marshal()) - full_build_plan = build_planner.get_build_plan() possible_build_plan = filter_plan( - full_build_plan, + self._app.BuildPlannerClass.unmarshal(project.marshal()).get_build_plan(), platform=parsed_args.platform, build_for=parsed_args.build_for, host_arch=None, @@ -179,22 +194,16 @@ def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: return 78 # Configuration error if parsed_args.build_for and not architectures: - if parsed_args.build_for in SUPPORTED_ARCHS: - # allow the user to build for a single architecture - # if the snapcraft.yaml not defined the platforms - architectures = [parsed_args.build_for] - else: - emit.progress( - f"build-for '{parsed_args.build_for}' is not supported.", - permanent=True, + if parsed_args.build_for not in SUPPORTED_ARCHS: + raise errors.RemoteBuildError( + f"build-for '{parsed_args.build_for}' is not supported.", retcode=78 ) - return 78 # Configuration error + # Allow the user to build for a single architecture if snapcraft.yaml + # doesn't define architectures. + architectures = [parsed_args.build_for] emit.debug(f"Architectures to build: {architectures}") - if not architectures: - architectures = None - if parsed_args.launchpad_timeout: emit.debug(f"Setting timeout to {parsed_args.launchpad_timeout} seconds") builder.set_timeout(parsed_args.launchpad_timeout) @@ -208,14 +217,16 @@ def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: "Starting new build. It may take a while to upload large projects." ) try: - builds = builder.start_builds(project_dir, architectures=architectures) + builds = builder.start_builds( + project_dir, architectures=architectures or None + ) except RemoteBuildError: emit.progress("Starting build failed.", permanent=True) emit.progress("Cleaning up") builder.cleanup() raise except lazr.restfulclient.errors.Conflict: - emit.progress("Remote repository is already existing.", permanent=True) + emit.progress("Remote repository already exists.", permanent=True) emit.progress("Cleaning up") builder.cleanup() return 75 @@ -226,13 +237,10 @@ def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: if confirm_with_user("Cancel builds?", default=True): emit.progress("Cancelling builds.") builder.cancel_builds() - emit.progress("Cleaning up") - builder.cleanup() returncode = 0 - else: - if returncode != 75: # TimeoutError - emit.progress("Cleaning up") - builder.cleanup() + if returncode != 75: # TimeoutError + emit.progress("Cleaning up") + builder.cleanup() return returncode def _monitor_and_complete( diff --git a/snapcraft/meta/component_yaml.py b/snapcraft/meta/component_yaml.py index be2cc5fcf0..5b8797c4b1 100644 --- a/snapcraft/meta/component_yaml.py +++ b/snapcraft/meta/component_yaml.py @@ -25,7 +25,11 @@ class ComponentMetadata(BaseMetadata): - """The component.yaml model.""" + """The component.yaml model. + + Component hooks are not included in the component's metadata. + Instead, they are included in the snap's metadata. + """ component: str type: str diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py index 0e81be1cbe..7c6d686e5f 100644 --- a/snapcraft/meta/snap_yaml.py +++ b/snapcraft/meta/snap_yaml.py @@ -235,6 +235,7 @@ class ComponentMetadata(SnapcraftMetadata): # type: ignore # (pydantic plugin i summary: SummaryStr description: str type: str + hooks: dict[str, models.Hook] | None @override class Config(BaseMetadata.Config): diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index 1d5a236114..cc42aeb29f 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -541,6 +541,7 @@ class Component(models.CraftBaseModel): description: str type: Literal["test"] version: Optional[VersionStr] # type: ignore[assignment] + hooks: dict[str, Hook] | None @pydantic.validator("version") @classmethod diff --git a/snapcraft/services/remotebuild.py b/snapcraft/services/remotebuild.py index a9ff6df595..3b53a25db2 100644 --- a/snapcraft/services/remotebuild.py +++ b/snapcraft/services/remotebuild.py @@ -15,33 +15,12 @@ # along with this program. If not, see . """Snapcraft Lifecycle Service.""" -from collections.abc import Collection -from typing import Any from craft_application import launchpad from craft_application.services import remotebuild -from overrides import override class RemoteBuild(remotebuild.RemoteBuildService): """Snapcraft remote build service.""" RecipeClass = launchpad.models.SnapRecipe - - @override - def _new_recipe( - self, - name: str, - repository: launchpad.models.GitRepository, - architectures: Collection[str] | None = None, - **_: Any, # noqa: ANN401 - ) -> launchpad.models.Recipe: - """Create a new recipe.""" - return launchpad.models.SnapRecipe.new( - self.lp, - name, - self.lp.username, - architectures=architectures, - project=self._lp_project.name, - git_ref=repository.git_https_url, - ) diff --git a/tests/spread/core22/components/expected-snap.yaml b/tests/spread/core22/components/expected-snap.yaml new file mode 100644 index 0000000000..666523c206 --- /dev/null +++ b/tests/spread/core22/components/expected-snap.yaml @@ -0,0 +1,25 @@ +name: hello-components +version: '1.0' +summary: Build a snap with components +description: Build a snap with components +architectures: +- amd64 +base: core22 +apps: + hello: + command: bin/hello +confinement: strict +grade: devel +environment: + LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH +components: + share: + summary: Hello World + description: Hello World + type: test + hooks: + install: + plugs: + - home + - network diff --git a/tests/spread/core22/components/snapcraft.yaml b/tests/spread/core22/components/snapcraft.yaml index 2bc7174e5e..1271676ca4 100644 --- a/tests/spread/core22/components/snapcraft.yaml +++ b/tests/spread/core22/components/snapcraft.yaml @@ -16,6 +16,9 @@ components: summary: Hello World description: Hello World version: "1.0" + hooks: + install: + plugs: ["home", "network"] parts: hello: diff --git a/tests/spread/core22/components/task.yaml b/tests/spread/core22/components/task.yaml index 2c8f3c1905..f53e18bbc9 100644 --- a/tests/spread/core22/components/task.yaml +++ b/tests/spread/core22/components/task.yaml @@ -11,13 +11,19 @@ restore: | execute: | snapcraft pack - # assert contents of default partition + # assert snap contents unsquashfs -dest "snap-contents" "hello-components_1.0_amd64.snap" if [ ! -e "snap-contents/bin/hello" ]; then echo "Expected 'bin/hello' in snap contents" exit 1 fi + # assert snap metadata + if ! diff -u snap-contents/meta/snap.yaml expected-snap.yaml; then + echo "Metadata for the snap is incorrect." + exit 1 + fi + # assert component was packed component_file="hello-components+share_1.0.comp" if [ ! -e "${component_file}" ]; then @@ -38,7 +44,7 @@ execute: | fi # assert contents of component metadata - if ! diff component-contents/meta/component.yaml expected-component.yaml; then + if ! diff -u component-contents/meta/component.yaml expected-component.yaml; then echo "Metadata for the share component is incorrect." exit 1 fi diff --git a/tests/spread/core24/components/expected-snap.yaml b/tests/spread/core24/components/expected-snap.yaml new file mode 100644 index 0000000000..ec39cb42a2 --- /dev/null +++ b/tests/spread/core24/components/expected-snap.yaml @@ -0,0 +1,25 @@ +name: hello-components +version: '1.0' +summary: Build a snap with components +description: Build a snap with components +architectures: +- amd64 +base: core24 +apps: + hello: + command: bin/hello +confinement: strict +grade: devel +environment: + LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH +components: + share: + summary: Hello World + description: Hello World + type: test + hooks: + install: + plugs: + - home + - network diff --git a/tests/spread/core24/components/snapcraft.yaml b/tests/spread/core24/components/snapcraft.yaml index 6c9079a33b..ecacc9e443 100644 --- a/tests/spread/core24/components/snapcraft.yaml +++ b/tests/spread/core24/components/snapcraft.yaml @@ -17,6 +17,9 @@ components: summary: Hello World description: Hello World version: "1.0" + hooks: + install: + plugs: ["home", "network"] parts: hello: diff --git a/tests/spread/core24/components/task.yaml b/tests/spread/core24/components/task.yaml index 2c8f3c1905..f53e18bbc9 100644 --- a/tests/spread/core24/components/task.yaml +++ b/tests/spread/core24/components/task.yaml @@ -11,13 +11,19 @@ restore: | execute: | snapcraft pack - # assert contents of default partition + # assert snap contents unsquashfs -dest "snap-contents" "hello-components_1.0_amd64.snap" if [ ! -e "snap-contents/bin/hello" ]; then echo "Expected 'bin/hello' in snap contents" exit 1 fi + # assert snap metadata + if ! diff -u snap-contents/meta/snap.yaml expected-snap.yaml; then + echo "Metadata for the snap is incorrect." + exit 1 + fi + # assert component was packed component_file="hello-components+share_1.0.comp" if [ ! -e "${component_file}" ]; then @@ -38,7 +44,7 @@ execute: | fi # assert contents of component metadata - if ! diff component-contents/meta/component.yaml expected-component.yaml; then + if ! diff -u component-contents/meta/component.yaml expected-component.yaml; then echo "Metadata for the share component is incorrect." exit 1 fi diff --git a/tests/spread/general/store/task.yaml b/tests/spread/general/store/task.yaml index 651cbf3fe9..2a41cf3f47 100644 --- a/tests/spread/general/store/task.yaml +++ b/tests/spread/general/store/task.yaml @@ -3,7 +3,9 @@ summary: Test the store workflow manual: true environment: - SNAP: "/snapcraft/tests/spread/core22/components/" + # use a core22 snap with components but no component hooks + # this can be changed after the snap store and review-tools have more support for components + SNAP: "/snapcraft/tests/spread/core22/components-environment/" SNAPCRAFT_STORE_CREDENTIALS/ubuntu_one: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING})" SNAPCRAFT_STORE_CREDENTIALS/legacy: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING_LEGACY})" SNAPCRAFT_STORE_CREDENTIALS/candid: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING_CANDID})" @@ -52,7 +54,8 @@ execute: | # Get information about our snap. cd "$SNAP" snap_file=$(ls ./*.snap) - component_file=$(ls ./*.comp) + foo_component_file=$(ls ./test-snapcraft*+foo_1.0.comp) + bar_baz_component_file=$(ls ./test-snapcraft*+bar-baz_1.0.comp) snap_name=$(grep "name: " snapcraft.yaml | sed -e "s/name: \(.*$\)/\1/") # Login mechanism @@ -68,7 +71,7 @@ execute: | snapcraft list # Push and Release - snapcraft upload "${snap_file}" --release edge --component "share=${component_file}" + snapcraft upload "${snap_file}" --release edge --component "foo=${foo_component_file}" --component "bar-baz=${bar_baz_component_file}" # Show revisions snapcraft list-revisions "${snap_name}" diff --git a/tests/unit/commands/test_remote.py b/tests/unit/commands/test_remote.py index d536c5d1d3..e4de911a47 100644 --- a/tests/unit/commands/test_remote.py +++ b/tests/unit/commands/test_remote.py @@ -154,16 +154,56 @@ def mock_run_legacy(mocker): @pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) -@pytest.mark.usefixtures("mock_argv", "fake_services") +@pytest.mark.parametrize("project_name", ["something", "something-else"]) +def test_set_project( + mocker, snapcraft_yaml, base, mock_confirm, fake_services, project_name +): + """Check that a project name gets set if the user provides a project.""" + mocker.patch("sys.argv", ["snapcraft", "remote-build", "--project", project_name]) + + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + app = application.create_app() + remote_build = app.services.remote_build + remote_build.is_project_private = lambda: False + + app.run() + + assert remote_build._project_name == project_name + mock_confirm.assert_called_once() + + +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("project_name", ["something", "something_else"]) +def test_no_confirmation_for_private_project( + mocker, snapcraft_yaml, base, mock_confirm, fake_services, project_name +): + """If a user uploads to a private project, we don't need a confirmation prompt.""" + mocker.patch("sys.argv", ["snapcraft", "remote-build", "--project", project_name]) + + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + app = application.create_app() + remote_build = app.services.remote_build + remote_build.is_project_private = lambda: True + + app.run() + + assert remote_build._project_name == project_name + mock_confirm.assert_not_called() + + +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv") def test_command_user_confirms_upload( - snapcraft_yaml, - base, - mock_confirm, + snapcraft_yaml, base, mock_confirm, fake_services ): """Check if the confirmation prompt is shown.""" snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} snapcraft_yaml(**snapcraft_yaml_dict) + fake_services.remote_build.is_project_private = lambda: False app = application.create_app() + app.run() mock_confirm.assert_called_once_with( @@ -1352,7 +1392,7 @@ def test_build_for_no_platforms( @pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) def test_build_for_error( - emitter, + capsys, mocker, snapcraft_yaml, base, @@ -1382,7 +1422,9 @@ def test_build_for_error( app = application.create_app() assert app.run() == 78 - emitter.assert_progress("build-for 'nonexistent' is not supported.", permanent=True) + _, err = capsys.readouterr() + + assert "build-for 'nonexistent' is not supported." in err ################## diff --git a/tests/unit/meta/test_component_yaml.py b/tests/unit/meta/test_component_yaml.py index 3ec0d78162..9d75289c83 100644 --- a/tests/unit/meta/test_component_yaml.py +++ b/tests/unit/meta/test_component_yaml.py @@ -50,6 +50,10 @@ def stub_project_data(): "summary": "test summary", "description": "test description", "version": "1.0", + "hooks": { + "install": {"plugs": ["home", "network"]}, + "post-refresh": {}, + }, }, }, } diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py index 60e2ef87fe..aa2f0a42fe 100644 --- a/tests/unit/meta/test_snap_yaml.py +++ b/tests/unit/meta/test_snap_yaml.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-2024 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -16,11 +16,12 @@ import textwrap from pathlib import Path +from typing import cast import pydantic import pytest import yaml -from craft_application.models import SummaryStr, VersionStr +from craft_application.models import SummaryStr, UniqueStrList, VersionStr from snapcraft import models from snapcraft.meta import snap_yaml @@ -418,6 +419,21 @@ def complex_project(): description: test type: test version: "1.0" + hooks: + install: + command-chain: + - test + environment: + test-variable-1: test + test-variable-2: test + plugs: + - home + - network + passthrough: + somefield: + - some + - value + post-refresh: {} component-b: summary: test description: test @@ -558,6 +574,21 @@ def test_complex_snap_yaml(complex_project, new_dir): summary: test description: test type: test + hooks: + install: + command-chain: + - test + environment: + test-variable-1: test + test-variable-2: test + plugs: + - home + - network + passthrough: + somefield: + - some + - value + post-refresh: {} component-b: summary: test description: test @@ -1339,6 +1370,14 @@ def test_component_metadata_from_component(): description="test", type="test", version=VersionStr("1.0"), + hooks={ + "install": models.Hook( + plugs=cast(UniqueStrList, ["home", "network"]), + command_chain=["test"], + environment={"test-variable-1": "test", "test-variable-2": "test"}, + passthrough={"somefield": ["some", "value"]}, + ) + }, ) metadata = snap_yaml.ComponentMetadata.from_component(component) @@ -1346,3 +1385,4 @@ def test_component_metadata_from_component(): assert metadata.summary == component.summary assert metadata.description == component.description assert metadata.type == component.type + assert metadata.hooks == component.hooks diff --git a/tests/unit/models/test_projects.py b/tests/unit/models/test_projects.py index 90e044f6ac..34c81ba3f2 100644 --- a/tests/unit/models/test_projects.py +++ b/tests/unit/models/test_projects.py @@ -2050,12 +2050,14 @@ class TestComponents: @pytest.fixture def stub_component_data(self): - return { + data: dict[str, Any] = { "type": "test", "summary": "test summary", "description": "test description", "version": "1.0", + "hooks": None, } + return data def test_components_valid(self, project, project_yaml_data, stub_component_data): components = {"foo": stub_component_data, "bar": stub_component_data} diff --git a/tests/unit/services/test_package_components.py b/tests/unit/services/test_package_components.py index 3598f5e909..acdf2325f9 100644 --- a/tests/unit/services/test_package_components.py +++ b/tests/unit/services/test_package_components.py @@ -37,6 +37,18 @@ def extra_project_params(extra_project_params): "summary": SummaryStr("first component"), "description": "lorem ipsum", "version": VersionStr("1.0"), + "hooks": { + "install": { + "command-chain": ["test"], + "environment": { + "test-variable-1": "test", + "test-variable-2": "test", + }, + "plugs": ["home", "network"], + "passthrough": {"somefield": ["some", "value"]}, + }, + "post-refresh": {}, + }, }, "secondcomponent": { "type": "test", @@ -133,6 +145,21 @@ def test_write_metadata( summary: first component description: lorem ipsum type: test + hooks: + install: + command-chain: + - test + environment: + test-variable-1: test + test-variable-2: test + plugs: + - home + - network + passthrough: + somefield: + - some + - value + post-refresh: {} secondcomponent: summary: second component description: lorem ipsum diff --git a/tests/unit/services/test_remotebuild.py b/tests/unit/services/test_remotebuild.py deleted file mode 100644 index 071d7dcedb..0000000000 --- a/tests/unit/services/test_remotebuild.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2024 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for the Snapcraft Remote Build service.""" -from unittest import mock - - -def test_new_snap_recipe(mocker, remote_build_service): - """Test that a new SnapRecipe is created.""" - mock_new_recipe = mocker.patch("craft_application.launchpad.models.SnapRecipe.new") - git_repo = mock.Mock(self_link="http://whatever") - - remote_build_service._lp_project = mock.Mock() - remote_build_service._lp_project.name = "mytest" - - remote_build_service._new_recipe( - name="mytest", - repository=git_repo, - architectures=["riscv64"], - ) - - mock_new_recipe.assert_called_once_with( - remote_build_service.lp, - "mytest", - "craft_test_user", - architectures=["riscv64"], - project="mytest", - git_ref=git_repo.git_https_url, - )