diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 256bc582db..0a11f13634 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,6 +5,8 @@ on: push: branches: - "feature/**" + # allow manual re-publishing as branches expire after 30 days + workflow_dispatch: jobs: publish: diff --git a/.github/workflows/security-scan.yaml b/.github/workflows/security-scan.yaml new file mode 100644 index 0000000000..20770c8ad9 --- /dev/null +++ b/.github/workflows/security-scan.yaml @@ -0,0 +1,17 @@ +name: Security scan +on: + pull_request: + push: + branches: + - main + - hotfix/* + - work/secscan # For development + +jobs: + python-scans: + name: Scan Python project + uses: canonical/starflow/.github/workflows/scan-python.yaml@main + with: + packages: python-apt-dev + osv-extra-args: '--config=source/osv-scanner.toml' + trivy-extra-args: '--severity HIGH,CRITICAL --ignore-unfixed --skip-dirs "tests/spread/**"' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a5c3f506a4..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,78 +0,0 @@ -language: bash -dist: bionic - -env: - global: - - LC_ALL: "C.UTF-8" - - LANG: "C.UTF-8" - - PATH: "/snap/bin:$PATH" - -jobs: - include: - - stage: snap - name: snap - workspaces: - create: - name: snaps - paths: - - "snapcraft-pr$TRAVIS_PULL_REQUEST.snap" - addons: - snaps: - - name: snapcraft - channel: stable - classic: true - - name: transfer - - name: http - apt: - packages: - - libapt-pkg-dev - - libffi-dev - - libnacl-dev - - libsodium-dev - - libssl-dev - - libyaml-dev - - python3.6-dev - script: - - snapcraft snap --destructive-mode --output "snapcraft-pr$TRAVIS_PULL_REQUEST.snap" - - sudo snap install "snapcraft-pr$TRAVIS_PULL_REQUEST.snap" --dangerous --classic - - snapcraft clean --destructive-mode - - snapcraft snap --destructive-mode --output "snapcraft-pr$TRAVIS_PULL_REQUEST.snap" - after_success: - - timeout 180 /snap/bin/transfer snapcraft-pr$TRAVIS_PULL_REQUEST.snap - after_failure: - - sudo journalctl -u snapd - - /snap/bin/http https://api.snapcraft.io/v2/snaps/info/core architecture==amd64 Snap-Device-Series:16 - - - stage: integration - name: store - workspaces: - use: snaps - if: head_repo = "canonical/snapcraft" - script: - - ./runtests.sh spread "google:ubuntu-18.04-64:tests/spread/general/store" - - - stage: docker - env: - RISK: "stable" - script: - - cd docker - - docker build --no-cache -f ${RISK}.Dockerfile --tag canonical/snapcraft:${RISK} . - - docker run canonical/snapcraft:${RISK} snapcraft --version - - env: - RISK: "edge" - script: - - cd docker - - docker build --no-cache -f ${RISK}.Dockerfile --tag canonical/snapcraft:${RISK} . - - docker run canonical/snapcraft:${RISK} snapcraft --version - - env: - RISK: "beta" - script: - - cd docker - - docker build --no-cache -f ${RISK}.Dockerfile --tag canonical/snapcraft:${RISK} . - - docker run canonical/snapcraft:${RISK} snapcraft --version - - env: - RISK: "candidate" - script: - - cd docker - - docker build --no-cache -f ${RISK}.Dockerfile --tag canonical/snapcraft:${RISK} . - - docker run canonical/snapcraft:${RISK} snapcraft --version diff --git a/HACKING.md b/HACKING.md index 68d976d7a5..ab505761f3 100644 --- a/HACKING.md +++ b/HACKING.md @@ -98,8 +98,9 @@ tox run -e lint-codespell ## Evaluating pull requests -Oftentimes all you want to do is see if a given pull request solves the issue you were having. To make this easier, the Travis CI setup for snapcraft _publishes_ the resulting snap that was built for x86-64 using `transfer.sh`. -To download the snap, find the relevant CI job run for the PR under review and locate the "snap" stage, the URL to download from will be located at the end of logs for that job. +Oftentimes all you want to do is see if a given pull request solves the issue you were having. To make this easier, a snap is published for `amd64` on a channel named `latest/edge/pr-` where `PR number` is the number of the pull request. + +For feature branches, a snap is published for `amd64` on a channel named `latest/edge/`. For example, a branch named `feature/offline-mode` would be available on the channel `latest/edge/offline-mode`. ## Reaching out diff --git a/README.md b/README.md index a8b6e875f8..e8b6707df8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![snapcraft](https://snapcraft.io/snapcraft/badge.svg)](https://snapcraft.io/snapcraft) -[![Build Status][travis-image]][travis-url] [![Documentation Status](https://readthedocs.com/projects/canonical-snapcraft/badge/?version=latest)](https://canonical-snapcraft.readthedocs-hosted.com/en/latest/?badge=latest) [![Scheduled spread tests](https://github.com/canonical/snapcraft/actions/workflows/spread-scheduled.yaml/badge.svg?branch=main)](https://github.com/canonical/snapcraft/actions/workflows/spread-scheduled.yaml) [![Coverage Status][codecov-image]][codecov-url] @@ -29,8 +28,5 @@ Learn about the latest features by following Snapcraft on We love contributors. Read the [hacking guide](HACKING.md) if you're interested in helping out. -[travis-image]: https://travis-ci.org/canonical/snapcraft.svg?branch=master -[travis-url]: https://travis-ci.org/canonical/snapcraft - [codecov-image]: https://codecov.io/github/canonical/snapcraft/coverage.svg?branch=master [codecov-url]: https://codecov.io/github/canonical/snapcraft?branch=master diff --git a/TESTING.md b/TESTING.md index 71a0be2e93..2d4671d6ce 100644 --- a/TESTING.md +++ b/TESTING.md @@ -40,14 +40,6 @@ These tests are in the `tests/integration` directory, with the `snapcraft.yamls` At any time, an integration test may fail and given the use of temporary directories it can be hard to inspect what went on. When working on a specific test case you can set the environment variable `SNAPCRAFT_TEST_KEEP_DATA_PATH` to a directory path for the sepecic test. This mechanism will only work when working with individual tests and will fail to run with a batch of them. -### Slow tests - -Some tests take too long. This affects the pull requests because we have to wait for a long time, and they will make Travis CI timeout because we have only 50 minutes per suite in there. The solution is to tag these tests as slow, and don't run them in all pull requests. These tests will only be run in autopkgtests. - -To mark a test case as slow, set the class attribute `slow_test = True`. - -To run all the tests, including the slow ones, set the environment variable `SNAPCRAFT_SLOW_TESTS=1`. - ### Snaps tests The snaps tests is a suite of high-level tests that try to simulate real-world scenarios of a user interacting with snapcraft. They cover the call to snapcraft to generate a snap file from the source files of a fully functional project, the installation of the resulting snap, and the execution of the binaries and services of this snap. @@ -120,35 +112,6 @@ We can currently run a minimal subset of snapcraft integration tests on macOS. T For manual exploratory testing, the team has one mac machine available. -## Autopkgtests for the snapcraft deb - -Autopkgtests are tests for the project packaged as a deb. The unit tests are run during autopkgtests while the snapcraft deb is being built. Then the resulting deb is installed, and the integration and snaps suites are executed using the installed snapcraft. - - -### How to run on Xenial - -The easiest way is to use a LXC container. From the root of the project, run: - - sudo apt install autopkgtest - adt-run --unbuilt-tree . --apt-upgrade --- lxd ubuntu:xenial - -It's possible to select only one of the suites using `--testname`, for example: - - adt-run --unbuilt-tree . --apt-upgrade --testname=integrationtests --- lxd ubuntu:xenial - - -### How to run on Bionic - -The easiest way is to use a LXC container. From the root of the project, run: - - sudo apt install autopkgtest - autopkgtest . -U -- lxd ubuntu:xenial - -It's possible to select only one of the suites using `--test-name`, for example: - - autopkgtest . -U --test-name=integrationtests-spread -- lxd ubuntu:xenial - - ## Spread tests for the snapcraft snap [Spread](https://github.com/canonical/spread) is a system to distribute tests and execute them in different backends, in parallel. We are currently using spread only to run the integration suite using the installed snapcraft snap from the edge channel. diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index fbc344f8ac..42b485f78d 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -1,3 +1,5 @@ +:tocdepth: 2 + Changelog ********* diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000000..17da2fac14 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,4 @@ +[[IgnoredVulns]] +id = "CVE-2024-35195" +ignoreUntil = "2025-01-01T00:00:00Z" +reason = "Needed for requests-unixsocket, which we're replacing with requests-unixsocket2" diff --git a/requirements-devel.txt b/requirements-devel.txt index 2660b4f900..37f48f4751 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -199,5 +199,5 @@ yamllint==1.35.1 zipp==3.20.2 zope.deprecation==5.0 zope.interface==7.0.3 -python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz; sys.platform == "linux" +python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz ; sys.platform == "linux" pyinstaller==5.13.2; sys.platform == "win32" diff --git a/requirements-docs.txt b/requirements-docs.txt index 787942bceb..1504cc4323 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -147,4 +147,4 @@ websockets==12.0 wheel==0.44.0 ws4py==0.5.1 zipp==3.20.2 -python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz; sys.platform == "linux" +python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz ; sys.platform == "linux" diff --git a/requirements.txt b/requirements.txt index 638d3ddb58..75e74f446c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,4 +78,4 @@ wadllib==1.3.6 wheel==0.44.0 ws4py==0.5.1 zipp==3.20.2 -python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz; sys.platform == "linux" +python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz ; sys.platform == "linux" diff --git a/snapcraft/commands/registries.py b/snapcraft/commands/registries.py index 95fe349df2..d71d5b7613 100644 --- a/snapcraft/commands/registries.py +++ b/snapcraft/commands/registries.py @@ -82,6 +82,8 @@ class StoreEditRegistriesCommand(craft_application.commands.AppCommand): If the registries set does not exist, then a new registries set will be created. + If a key name is not provided, the default key is used. + The account ID of the authenticated account can be determined with the ``snapcraft whoami`` command. @@ -100,10 +102,14 @@ def fill_parser(self, parser: "argparse.ArgumentParser") -> None: parser.add_argument( "name", metavar="name", help="Name of the registries set to edit" ) + parser.add_argument( + "--key-name", metavar="key-name", help="Key used to sign the registries set" + ) @override def run(self, parsed_args: "argparse.Namespace"): self._services.registries.edit_assertion( name=parsed_args.name, account_id=parsed_args.account_id, + key_name=parsed_args.key_name, ) diff --git a/snapcraft/errors.py b/snapcraft/errors.py index 408c2fcb02..49c88f32ee 100644 --- a/snapcraft/errors.py +++ b/snapcraft/errors.py @@ -173,3 +173,10 @@ def __init__(self, message: str, *, resolution: str) -> None: resolution=resolution, docs_url="https://snapcraft.io/docs/snapcraft-authentication", ) + + +class SnapcraftAssertionError(SnapcraftError): + """Error raised when an assertion (validation or registries set) is invalid. + + Not to be confused with Python's built-in AssertionError. + """ diff --git a/snapcraft/models/assertions.py b/snapcraft/models/assertions.py index d557ff9092..efe6a30a4e 100644 --- a/snapcraft/models/assertions.py +++ b/snapcraft/models/assertions.py @@ -16,13 +16,44 @@ """Assertion models.""" -from typing import Literal +import numbers +from collections import abc +from typing import Any, Literal import pydantic from craft_application import models from typing_extensions import Self +def cast_dict_scalars_to_strings(data: dict) -> dict: + """Cast all scalars in a dictionary to strings. + + Supported scalar types are str, bool, and numbers. + """ + return {_to_string(key): _to_string(value) for key, value in data.items()} + + +def _to_string(data: Any) -> Any: + """Recurse through nested dicts and lists and cast scalar values to strings. + + Supported scalar types are str, bool, and numbers. + """ + # check for a string first, as it is the most common scenario + if isinstance(data, str): + return data + + if isinstance(data, abc.Mapping): + return {_to_string(key): _to_string(value) for key, value in data.items()} + + if isinstance(data, abc.Collection): + return [_to_string(i) for i in data] + + if isinstance(data, (numbers.Number, bool)): + return str(data) + + return data + + class Registry(models.CraftBaseModel): """Access and data definitions for a specific facet of a snap or system.""" @@ -52,7 +83,6 @@ class EditableRegistryAssertion(models.CraftBaseModel): """Issuer of the registry assertion and owner of the signing key.""" name: str - summary: str | None = None revision: int | None = 0 views: dict[str, Rules] @@ -61,6 +91,10 @@ class EditableRegistryAssertion(models.CraftBaseModel): body: str | None = None """A JSON schema that defines the storage structure.""" + def marshal_scalars_as_strings(self) -> dict[str, Any]: + """Marshal the model where all scalars are represented as strings.""" + return cast_dict_scalars_to_strings(self.marshal()) + class RegistryAssertion(EditableRegistryAssertion): """A full registries assertion containing editable and non-editable fields.""" diff --git a/snapcraft/services/assertions.py b/snapcraft/services/assertions.py index b3650b7883..c5aa03e876 100644 --- a/snapcraft/services/assertions.py +++ b/snapcraft/services/assertions.py @@ -33,6 +33,7 @@ from craft_application.errors import CraftValidationError from craft_application.services import base from craft_application.util import safe_yaml_load +from craft_store.errors import StoreServerError from typing_extensions import override from snapcraft import const, errors, models, store, utils @@ -68,6 +69,24 @@ def _get_assertions(self, name: str | None = None) -> list[models.Assertion]: :returns: A list of assertions. """ + @abc.abstractmethod + def _build_assertion(self, assertion: models.EditableAssertion) -> models.Assertion: + """Build an assertion from an editable assertion. + + :param assertion: The editable assertion to build. + + :returns: The built assertion. + """ + + @abc.abstractmethod + def _post_assertion(self, assertion_data: bytes) -> models.Assertion: + """Post an assertion to the store. + + :param assertion_data: A signed assertion represented as bytes. + + :returns: The published assertion. + """ + @abc.abstractmethod def _normalize_assertions( self, assertions: list[models.Assertion] @@ -102,6 +121,15 @@ def _generate_yaml_from_template(self, name: str, account_id: str) -> str: :returns: A multi-line yaml string. """ + @abc.abstractmethod + def _get_success_message(self, assertion: models.Assertion) -> str: + """Create a message after an assertion has been successfully posted. + + :param assertion: The published assertion. + + :returns: The success message to log. + """ + def list_assertions(self, *, output_format: str, name: str | None = None) -> None: """List assertions from the store. @@ -150,6 +178,7 @@ def _edit_yaml_file(self, filepath: pathlib.Path) -> models.EditableAssertion: :returns: The edited assertion. """ + craft_cli.emit.progress(f"Editing {self._assertion_name}.") while True: craft_cli.emit.debug(f"Using {self._editor_cmd} to edit file.") with craft_cli.emit.pause(): @@ -161,8 +190,9 @@ def _edit_yaml_file(self, filepath: pathlib.Path) -> models.EditableAssertion: data=data, # filepath is only shown for pydantic errors and snapcraft should # not expose the temp file name - filepath=pathlib.Path(self._assertion_name.replace(" ", "-")), + filepath=pathlib.Path(self._assertion_name), ) + craft_cli.emit.progress(f"Edited {self._assertion_name}.") return edited_assertion except (yaml.YAMLError, CraftValidationError) as err: craft_cli.emit.message(f"{err!s}") @@ -178,12 +208,12 @@ def _get_yaml_data(self, name: str, account_id: str) -> str: if assertions := self._get_assertions(name=name): yaml_data = self._generate_yaml_from_model(assertions[0]) + craft_cli.emit.progress( + f"Retrieved {self._assertion_name} '{name}' from the store.", + ) else: craft_cli.emit.progress( - f"Creating a new {self._assertion_name} because no existing " - f"{self._assertion_name} named '{name}' was found for the " - "authenticated account.", - permanent=True, + f"Could not find an existing {self._assertion_name} named '{name}'.", ) yaml_data = self._generate_yaml_from_template( name=name, account_id=account_id @@ -204,30 +234,83 @@ def _remove_temp_file(filepath: pathlib.Path) -> None: craft_cli.emit.trace(f"Removing temporary file '{filepath}'.") filepath.unlink() - def edit_assertion(self, *, name: str, account_id: str) -> None: + @staticmethod + def _sign_assertion(assertion: models.Assertion, key_name: str | None) -> bytes: + """Sign an assertion with `snap sign`. + + :param assertion: The assertion to sign. + :param key_name: Name of the key to sign the assertion. + + :returns: A signed assertion represented as bytes. + """ + craft_cli.emit.progress("Signing assertion.") + cmdline = ["snap", "sign"] + if key_name: + cmdline += ["-k", key_name] + + # snapd expects a json string where all scalars are strings + unsigned_assertion = json.dumps(assertion.marshal_scalars_as_strings()) + + try: + # pause the emitter for passphrase prompts + with craft_cli.emit.pause(): + signed_assertion = subprocess.check_output( + cmdline, input=unsigned_assertion.encode() + ) + except subprocess.CalledProcessError as sign_error: + raise errors.SnapcraftAssertionError( + "Failed to sign assertion" + ) from sign_error + + craft_cli.emit.progress("Signed assertion.") + craft_cli.emit.trace(f"Signed assertion: {signed_assertion.decode()}") + return signed_assertion + + def edit_assertion( + self, *, name: str, account_id: str, key_name: str | None = None + ) -> None: """Edit, sign and upload an assertion. If the assertion does not exist, a new assertion is created from a template. :param name: The name of the assertion to edit. :param account_id: The account ID associated with the registries set. + :param key_name: Name of the key to sign the assertion. """ yaml_data = self._get_yaml_data(name=name, account_id=account_id) yaml_file = self._write_to_file(yaml_data) original_assertion = self._editable_assertion_class.unmarshal( safe_yaml_load(io.StringIO(yaml_data)) ) - edited_assertion = self._edit_yaml_file(yaml_file) - if edited_assertion == original_assertion: - craft_cli.emit.message("No changes made.") + try: + while True: + try: + edited_assertion = self._edit_yaml_file(yaml_file) + if edited_assertion == original_assertion: + craft_cli.emit.message("No changes made.") + break + + craft_cli.emit.progress(f"Building {self._assertion_name}.") + built_assertion = self._build_assertion(edited_assertion) + craft_cli.emit.progress(f"Built {self._assertion_name}.") + + signed_assertion = self._sign_assertion(built_assertion, key_name) + published_assertion = self._post_assertion(signed_assertion) + craft_cli.emit.message( + self._get_success_message(published_assertion) + ) + break + except ( + StoreServerError, + errors.SnapcraftAssertionError, + ) as assertion_error: + craft_cli.emit.message(str(assertion_error)) + if not utils.confirm_with_user( + f"Do you wish to amend the {self._assertion_name}?" + ): + raise errors.SnapcraftError( + "operation aborted" + ) from assertion_error + finally: self._remove_temp_file(yaml_file) - return - - # TODO: build, sign, and push assertion (#5018) - - self._remove_temp_file(yaml_file) - craft_cli.emit.message(f"Successfully edited {self._assertion_name} {name!r}.") - raise errors.FeatureNotImplemented( - f"Building, signing and uploading {self._assertion_name} is not implemented.", - ) diff --git a/snapcraft/services/registries.py b/snapcraft/services/registries.py index e6cd785c16..ce4de0c18f 100644 --- a/snapcraft/services/registries.py +++ b/snapcraft/services/registries.py @@ -31,7 +31,6 @@ """\ account-id: {account_id} name: {set_name} - # summary: {summary} # The revision for this registries set # revision: {revision} {views} @@ -85,6 +84,14 @@ def _editable_assertion_class(self) -> type[models.EditableAssertion]: def _get_assertions(self, name: str | None = None) -> list[models.Assertion]: return self._store_client.list_registries(name=name) + @override + def _build_assertion(self, assertion: models.EditableAssertion) -> models.Assertion: + return self._store_client.build_registries(registries=assertion) + + @override + def _post_assertion(self, assertion_data: bytes) -> models.Assertion: + return self._store_client.post_registries(registries_data=assertion_data) + @override def _normalize_assertions( self, assertions: list[models.Assertion] @@ -110,7 +117,6 @@ def _generate_yaml_from_model(self, assertion: models.Assertion) -> str: {"views": assertion.marshal().get("views")}, default_flow_style=False ), body=dump_yaml({"body": assertion.body}, default_flow_style=False), - summary=assertion.summary, set_name=assertion.name, revision=assertion.revision, ) @@ -121,7 +127,10 @@ def _generate_yaml_from_template(self, name: str, account_id: str) -> str: account_id=account_id, views=_REGISTRY_SETS_VIEWS_TEMPLATE, body=_REGISTRY_SETS_BODY_TEMPLATE, - summary="A brief summary of the registries set", set_name=name, revision=1, ) + + @override + def _get_success_message(self, assertion: models.Assertion) -> str: + return f"Successfully created revision {assertion.revision!r} for {assertion.name!r}." diff --git a/snapcraft/store/client.py b/snapcraft/store/client.py index bbb58aed43..6170c5d8e2 100644 --- a/snapcraft/store/client.py +++ b/snapcraft/store/client.py @@ -23,7 +23,9 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, cast import craft_store +import pydantic import requests +from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import emit from overrides import overrides @@ -34,8 +36,6 @@ from ._legacy_account import LegacyUbuntuOne from .onprem_client import ON_PREM_ENDPOINTS, OnPremClient -_TESTING_ENV_PREFIXES = ["TRAVIS", "AUTOPKGTEST_TMP"] - _POLL_DELAY = 1 _HUMAN_STATUS = { "being_processed": "processing", @@ -51,13 +51,7 @@ def build_user_agent( os_platform: utils.OSPlatform = utils.get_os_platform(), # noqa: B008 ): """Build Snapcraft's user agent.""" - if any( - key.startswith(prefix) for prefix in _TESTING_ENV_PREFIXES for key in os.environ - ): - testing = " (testing) " - else: - testing = " " - return f"snapcraft/{version}{testing}{os_platform!s}" + return f"snapcraft/{version} {os_platform!s}" def use_candid() -> bool: @@ -503,6 +497,22 @@ def list_revisions(self, snap_name: str) -> Revisions: return Revisions.unmarshal(response.json()) + @staticmethod + def _unmarshal_registries_set(registries_data) -> models.RegistryAssertion: + """Unmarshal a registries set. + + :raises StoreAssertionError: If the registries set cannot be unmarshalled. + """ + try: + return models.RegistryAssertion.unmarshal(registries_data) + except pydantic.ValidationError as err: + raise errors.SnapcraftAssertionError( + message="Received invalid registries set from the store", + # this is an unexpected failure that the user can't fix, so hide + # the response in the details + details=f"{format_pydantic_errors(err.errors(), file_name='registries set')}", + ) from err + def list_registries( self, *, name: str | None = None ) -> list[models.RegistryAssertion]: @@ -526,16 +536,75 @@ def list_registries( registry_assertions = [] if assertions := response.json().get("assertions"): for assertion_data in assertions: - emit.debug(f"Parsing assertion: {assertion_data}") # move body into model - assertion_data["headers"]["body"] = assertion_data["body"] - assertion = models.RegistryAssertion.unmarshal( - assertion_data["headers"] - ) + assertion_data["headers"]["body"] = assertion_data.get("body") + + assertion = self._unmarshal_registries_set(assertion_data["headers"]) registry_assertions.append(assertion) + emit.debug(f"Parsed registries set: {assertion.model_dump_json()}") return registry_assertions + def build_registries( + self, *, registries: models.EditableRegistryAssertion + ) -> models.RegistryAssertion: + """Build a registries set. + + Sends an edited registries set to the store, which validates the data, + populates additional fields, and returns the registries set. + + :param registries: The registries set to build. + + :returns: The built registries set. + """ + response = self.request( + "POST", + f"{self._base_url}/api/v2/registries/build-assertion", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=registries.marshal(), + ) + + assertion = self._unmarshal_registries_set(response.json()) + emit.debug(f"Built registries set: {assertion.model_dump_json()}") + return assertion + + def post_registries(self, *, registries_data: bytes) -> models.RegistryAssertion: + """Send a registries set to be published. + + :param registries_data: A signed registries set represented as bytes. + + :returns: The published assertion. + """ + response = self.request( + "POST", + f"{self._base_url}/api/v2/registries", + headers={ + "Accept": "application/json", + "Content-Type": "application/x.ubuntu.assertion", + }, + data=registries_data, + ) + + assertions = response.json().get("assertions") + + if not assertions or len(assertions) != 1: + raise errors.SnapcraftAssertionError( + message="Received invalid registries set from the store", + # this is an unexpected failure that the user can't fix, so hide + # the response in the details + details=f"Received data: {assertions}", + ) + + # move body into model + assertions[0]["headers"]["body"] = assertions[0]["body"] + + assertion = self._unmarshal_registries_set(assertions[0]["headers"]) + emit.debug(f"Published registries set: {assertion.model_dump_json()}") + return assertion + class OnPremStoreClientCLI(LegacyStoreClientCLI): """On Premises Store Client command line interface.""" diff --git a/snapcraft_legacy/storeapi/agent.py b/snapcraft_legacy/storeapi/agent.py index 04d516e75b..66c982829a 100644 --- a/snapcraft_legacy/storeapi/agent.py +++ b/snapcraft_legacy/storeapi/agent.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os import sys import snapcraft_legacy @@ -23,16 +22,6 @@ from snapcraft_legacy.internal.errors import OsReleaseNameError, OsReleaseVersionIdError -def _is_ci_env(): - env_prefixes = ["TRAVIS", "AUTOPKGTEST_TMP"] - matches = [] - - for prefix in env_prefixes: - matches += [var for var in os.environ.keys() if var.startswith(prefix)] - - return len(matches) > 0 - - def _get_linux_release(release: os_release.OsRelease) -> str: try: os_name = release.name() @@ -48,11 +37,10 @@ def _get_linux_release(release: os_release.OsRelease) -> str: def get_user_agent(platform: str = sys.platform) -> str: arch = project.Project().deb_arch - testing = "(testing) " if _is_ci_env() else "" if platform == "linux": os_platform = _get_linux_release(os_release.OsRelease()) else: os_platform = platform.title() - return f"snapcraft/{snapcraft_legacy.__version__} {testing}{os_platform} ({arch})" + return f"snapcraft/{snapcraft_legacy.__version__} {os_platform} ({arch})" diff --git a/spread.yaml b/spread.yaml index 039ef727b3..b7ac155b78 100644 --- a/spread.yaml +++ b/spread.yaml @@ -121,51 +121,6 @@ backends: username: root password: ubuntu - autopkgtest: - type: adhoc - allocate: | - echo "Allocating ad-hoc $SPREAD_SYSTEM" - if [ -z "${ADT_ARTIFACTS:-}" ]; then - FATAL "adhoc only works inside autopkgtest" - exit 1 - fi - echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/99-spread-users - ADDRESS localhost:22 - discard: echo "Discarding ad-hoc $SPREAD_SYSTEM" - systems: - # Focal - - ubuntu-20.04-amd64: - username: ubuntu - password: ubuntu - - ubuntu-20.04-ppc64el: - username: ubuntu - password: ubuntu - - ubuntu-20.04-armhf: - username: ubuntu - password: ubuntu - - ubuntu-20.04-s390x: - username: ubuntu - password: ubuntu - - ubuntu-20.04-arm64: - username: ubuntu - password: ubuntu - # Jammy - - ubuntu-22.04-amd64: - username: ubuntu - password: ubuntu - - ubuntu-22.04-ppc64el: - username: ubuntu - password: ubuntu - - ubuntu-22.04-armhf: - username: ubuntu - password: ubuntu - - ubuntu-22.04-s390x: - username: ubuntu - password: ubuntu - - ubuntu-22.04-arm64: - username: ubuntu - password: ubuntu - exclude: [snaps-cache/] prepare: | @@ -342,14 +297,12 @@ suites: - ubuntu-20.04-64 - ubuntu-22.04-64 kill-timeout: 180m - warn-timeout: 9m # Keep less than 10 minutes so Travis can't timeout priority: 90 # Run this test relatively early since fetching images can take time tests/spread/providers/legacy/: summary: tests of snapcraft using build providers systems: - ubuntu-20.04-64 kill-timeout: 180m - warn-timeout: 9m # Keep less than 10 minutes so Travis can't timeout priority: 90 # Run this test relatively early since fetching images can take time # Plugin-specific suites diff --git a/tests/legacy/unit/store/test_agent.py b/tests/legacy/unit/store/test_agent.py index 0150c68bc9..acad3a0e13 100644 --- a/tests/legacy/unit/store/test_agent.py +++ b/tests/legacy/unit/store/test_agent.py @@ -14,9 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os -import fixtures from testtools.matchers import Equals from snapcraft_legacy import ProjectOptions @@ -54,27 +52,3 @@ def test_user_agent_darwin(self): expected = f"snapcraft/{snapcraft_version} Darwin ({arch})" self.expectThat(agent.get_user_agent(platform="darwin"), Equals(expected)) - - def test_in_travis_ci_env(self): - self.useFixture(fixtures.EnvironmentVariable("TRAVIS_TESTING", "1")) - - self.assertTrue(agent._is_ci_env()) - - def test_in_autopkgtest_ci_env(self): - self.useFixture(fixtures.EnvironmentVariable("AUTOPKGTEST_TMP", "1")) - - self.assertTrue(agent._is_ci_env()) - - def test_not_in_ci_env(self): - # unset any known testing environment vars - testing_vars = ["TRAVIS", "AUTHPKGTEST_TMP"] - vars_to_unset = [] - for env_var in os.environ: - for test_var in testing_vars: - if env_var.startswith(test_var): - vars_to_unset.append(env_var) - - for var in vars_to_unset: - self.useFixture(fixtures.EnvironmentVariable(var, None)) - - self.assertFalse(agent._is_ci_env()) diff --git a/tests/spread/general/package-repositories/task.yaml b/tests/spread/general/package-repositories/task.yaml index 0c62af55b8..c7bfa0cb7f 100644 --- a/tests/spread/general/package-repositories/task.yaml +++ b/tests/spread/general/package-repositories/task.yaml @@ -30,12 +30,7 @@ execute: | cd "$SNAP" # Build what we have. - # We cannot use --use-lxd for autopkgtest so we resort to --destructive-mode there. - if [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then - snapcraft --use-lxd - else - snapcraft --destructive-mode - fi + snapcraft --use-lxd # And verify the snap runs as expected. snap install "${SNAP}"_1.0_*.snap --dangerous diff --git a/tests/spread/plugins/v2/colcon-ros2-daemon/task.yaml b/tests/spread/plugins/v2/colcon-ros2-daemon/task.yaml index e35c624bba..bb131dbbc8 100644 --- a/tests/spread/plugins/v2/colcon-ros2-daemon/task.yaml +++ b/tests/spread/plugins/v2/colcon-ros2-daemon/task.yaml @@ -1,5 +1,4 @@ summary: Build and run a basic daemon colcon snap -warn-timeout: 9m # Keep less than 10 minutes so Travis can't timeout priority: 100 # Run this test early so we're not waiting for it environment: diff --git a/tests/spread/store/validation-sets/editor.sh b/tests/spread/store/validation-sets/editor.sh index a38c96be47..eceab47d09 100755 --- a/tests/spread/store/validation-sets/editor.sh +++ b/tests/spread/store/validation-sets/editor.sh @@ -2,11 +2,11 @@ validation_set_file="$1" -# flip-flop between two valid revisions of `test-snapcraft-assertions` in the staging store: 1 and 2 -if grep -q "^ revision:.*1" "$validation_set_file"; then - (( revision=2 )) +# flip-flop between 'hello-world' being optional or required +if grep -q "^ presence:.*optional" "$validation_set_file"; then + presence="required" else - (( revision=1 )) + presence="optional" fi -sed -i "s/ revision:.*/ revision: $revision/g" "$validation_set_file" +sed -i "s/ presence:.*/ presence: $presence/g" "$validation_set_file" diff --git a/tests/unit/commands/test_registries.py b/tests/unit/commands/test_registries.py index c4f34dac92..f206fe3866 100644 --- a/tests/unit/commands/test_registries.py +++ b/tests/unit/commands/test_registries.py @@ -53,7 +53,6 @@ def test_list_registries(mocker, mock_list_assertions, output_format, name): @pytest.mark.parametrize("name", [None, "test"]) def test_list_registries_default_format(mocker, mock_list_assertions, name): """Default format is 'table'.""" - """Test `snapcraft list-registries`.""" cmd = ["snapcraft", "list-registries"] if name: cmd.extend(["--name", name]) @@ -65,15 +64,18 @@ def test_list_registries_default_format(mocker, mock_list_assertions, name): mock_list_assertions.assert_called_once_with(name=name, output_format="table") +@pytest.mark.parametrize("key_name", [None, "test-key"]) @pytest.mark.usefixtures("memory_keyring") -def test_edit_registries(mocker, mock_edit_assertion): +def test_edit_registries(key_name, mocker, mock_edit_assertion): """Test `snapcraft edit-registries`.""" cmd = ["snapcraft", "edit-registries", "test-account-id", "test-name"] + if key_name: + cmd.extend(["--key-name", key_name]) mocker.patch.object(sys, "argv", cmd) app = application.create_app() app.run() mock_edit_assertion.assert_called_once_with( - name="test-name", account_id="test-account-id" + name="test-name", account_id="test-account-id", key_name=key_name ) diff --git a/tests/unit/models/test_assertions.py b/tests/unit/models/test_assertions.py index a6fed23d95..16c6552347 100644 --- a/tests/unit/models/test_assertions.py +++ b/tests/unit/models/test_assertions.py @@ -17,7 +17,42 @@ """Tests for Assertion models.""" +import pytest + from snapcraft.models import EditableRegistryAssertion, Registry, RegistryAssertion +from snapcraft.models.assertions import cast_dict_scalars_to_strings + + +@pytest.mark.parametrize( + ("input_dict", "expected_dict"), + [ + pytest.param({}, {}, id="empty"), + pytest.param( + {False: False, True: True}, + {"False": "False", "True": "True"}, + id="boolean values", + ), + pytest.param( + {0: 0, None: None, "dict": {}, "list": [], "str": ""}, + ({"0": "0", None: None, "dict": {}, "list": [], "str": ""}), + id="none-like values", + ), + pytest.param( + {10: 10, 20.0: 20.0, "30": "30", True: True}, + {"10": "10", "20.0": "20.0", "30": "30", "True": "True"}, + id="scalar values", + ), + pytest.param( + {"foo": {"bar": [1, 2.0], "baz": {"qux": True}}}, + {"foo": {"bar": ["1", "2.0"], "baz": {"qux": "True"}}}, + id="nested data structures", + ), + ], +) +def test_cast_dict_scalars_to_strings(input_dict, expected_dict): + actual = cast_dict_scalars_to_strings(input_dict) + + assert actual == expected_dict def test_registry_defaults(check): @@ -73,11 +108,34 @@ def test_editable_registry_assertion_defaults(check): } ) - check.is_none(assertion.summary) check.equal(assertion.revision, 0) check.is_none(assertion.body) +def test_editable_registry_assertion_marshal_as_str(): + """Cast all scalars to string when marshalling.""" + assertion = EditableRegistryAssertion.unmarshal( + { + "account_id": "test-account-id", + "name": "test-registry", + "revision": 10, + "views": { + "wifi-setup": { + "rules": [ + { + "storage": "wifi.ssids", + } + ] + } + }, + } + ) + + assertion_dict = assertion.marshal_scalars_as_strings() + + assert assertion_dict["revision"] == "10" + + def test_registry_assertion_defaults(check): """Test default values of the RegistryAssertion model.""" assertion = RegistryAssertion.unmarshal( @@ -104,5 +162,31 @@ def test_registry_assertion_defaults(check): check.is_none(assertion.body) check.is_none(assertion.body_length) check.is_none(assertion.sign_key_sha3_384) - check.is_none(assertion.summary) check.equal(assertion.revision, 0) + + +def test_registry_assertion_marshal_as_str(): + """Cast all scalars to strings when marshalling.""" + assertion = RegistryAssertion.unmarshal( + { + "account_id": "test-account-id", + "authority_id": "test-authority-id", + "name": "test-registry", + "revision": 10, + "timestamp": "2024-01-01T10:20:30Z", + "type": "registry", + "views": { + "wifi-setup": { + "rules": [ + { + "storage": "wifi.ssids", + } + ] + } + }, + } + ) + + assertion_dict = assertion.marshal_scalars_as_strings() + + assert assertion_dict["revision"] == "10" diff --git a/tests/unit/services/test_assertions.py b/tests/unit/services/test_assertions.py index 73b15bc92e..56da17d207 100644 --- a/tests/unit/services/test_assertions.py +++ b/tests/unit/services/test_assertions.py @@ -16,15 +16,19 @@ """Tests for the abstract assertions service.""" +import json +import tempfile import textwrap from typing import Any from unittest import mock +import craft_store.errors import pytest from craft_application.models import CraftBaseModel from typing_extensions import override from snapcraft import const, errors +from tests.unit.store.utils import FakeResponse @pytest.fixture(autouse=True) @@ -48,8 +52,8 @@ def mock_confirm_with_user(mocker, request): @pytest.fixture -def mock_subprocess_run(mocker, tmp_path, request): - """Mock the subprocess.run function to write data to a file. +def write_text(mocker, tmp_path, request): + """Mock the subprocess.run function to write fake data to a temp assertion file. :param request: A list of strings to write to a file. Each time the subprocess.run function is called, the last string in the list will be written to the file @@ -65,12 +69,49 @@ def side_effect(*args, **kwargs): return subprocess_mock +@pytest.fixture +def fake_sign_assertion(mocker): + def _fake_sign(cmdline, input): # noqa: A002 (builtin-argument-shadowing) + return input + b"-signed" + + mock_subprocess = mocker.patch("subprocess.check_output") + mock_subprocess.side_effect = _fake_sign + return mock_subprocess + + +@pytest.fixture(autouse=True) +def mock_named_temporary_file(mocker, tmp_path): + _mock_tempfile = mocker.patch( + "tempfile.NamedTemporaryFile", spec=tempfile.NamedTemporaryFile + ) + _mock_tempfile.return_value.__enter__.return_value.name = str( + tmp_path / "assertion-file" + ) + yield _mock_tempfile.return_value + + +FAKE_STORE_ERROR = craft_store.errors.StoreServerError( + response=FakeResponse( + content=json.dumps( + {"error_list": [{"code": "bad assertion", "message": "bad assertion"}]} + ), + status_code=400, + ) +) + + class FakeAssertion(CraftBaseModel): """Fake assertion model.""" test_field_1: str test_field_2: int + def marshal_scalars_as_strings(self): + return { + "test_field_1": self.test_field_1, + "test_field_2": str(self.test_field_2), + } + @pytest.fixture def fake_assertion_service(default_factory): @@ -99,6 +140,21 @@ def _get_assertions( # type: ignore[override] FakeAssertion(test_field_1="test-value-2", test_field_2=100), ] + @override + def _build_assertion( # type: ignore[override] + self, assertion: FakeAssertion + ) -> FakeAssertion: + assertion.test_field_1 = assertion.test_field_1 + "-built" + return assertion + + @override + def _post_assertion( # type: ignore[override] + self, assertion_data: bytes + ) -> FakeAssertion: + return FakeAssertion( + test_field_1="test-published-assertion", test_field_2=0 + ) + @override def _normalize_assertions( # type: ignore[override] self, assertions: list[FakeAssertion] @@ -133,19 +189,13 @@ def _generate_yaml_from_template(self, name: str, account_id: str) -> str: """ ) - return FakeAssertionService(app=APP_METADATA, services=default_factory) - + @override + def _get_success_message( # type: ignore[override] + self, assertion: FakeAssertion + ) -> str: + return "Success." -@pytest.fixture -def fake_edit_yaml_file(mocker, fake_assertion_service): - """Apply a fake edit to a yaml file.""" - return mocker.patch.object( - fake_assertion_service, - "_edit_yaml_file", - return_value=FakeAssertion( - test_field_1="test-value-1-UPDATED", test_field_2=999 - ), - ) + return FakeAssertionService(app=APP_METADATA, services=default_factory) def test_list_assertions_table(fake_assertion_service, emitter): @@ -199,62 +249,213 @@ def test_list_assertions_unknown_format(fake_assertion_service): ) +@pytest.mark.parametrize( + "write_text", + [["test-field-1: test-value-1-edited\ntest-field-2: 999"]], + indirect=True, +) +@pytest.mark.usefixtures("fake_sign_assertion") def test_edit_assertions_changes_made( - fake_edit_yaml_file, fake_assertion_service, emitter + fake_assertion_service, + emitter, + mocker, + tmp_path, + write_text, ): """Edit an assertion and make a valid change.""" - expected = "Building, signing and uploading fake assertion is not implemented" - fake_assertion_service.setup() + expected_assertion = ( + b'{"test_field_1": "test-value-1-edited-built", "test_field_2": "999"}-signed' + ) + mock_post_assertion = mocker.spy(fake_assertion_service, "_post_assertion") - with pytest.raises(errors.FeatureNotImplemented, match=expected): - fake_assertion_service.edit_assertion( - name="test-registry", account_id="test-account-id" - ) + fake_assertion_service.setup() + fake_assertion_service.edit_assertion( + name="test-registry", account_id="test-account-id", key_name="test-key" + ) - emitter.assert_message("Successfully edited fake assertion 'test-registry'.") + mock_post_assertion.assert_called_once_with(expected_assertion) + emitter.assert_trace(f"Signed assertion: {expected_assertion.decode()}") + emitter.assert_message("Success.") +@pytest.mark.parametrize( + "write_text", + [["test-field-1: test-value-1\ntest-field-2: 0"]], + indirect=True, +) def test_edit_assertions_no_changes_made( - fake_edit_yaml_file, fake_assertion_service, emitter, mocker + fake_assertion_service, emitter, tmp_path, write_text ): """Edit an assertion but make no changes to the data.""" + fake_assertion_service.setup() + fake_assertion_service.edit_assertion( + name="test-registry", account_id="test-account-id" + ) + + emitter.assert_message("No changes made.") + assert not (tmp_path / "assertion-file").exists() + + +@pytest.mark.parametrize( + "write_text", + [ + [ + "test-field-1: test-value-1-edited-edited\ntest-field-2: 999", + "test-field-1: test-value-1-edited\ntest-field-2: 999", + ], + ], + indirect=True, +) +@pytest.mark.parametrize("mock_confirm_with_user", [True], indirect=True) +@pytest.mark.parametrize( + "error", [FAKE_STORE_ERROR, errors.SnapcraftAssertionError("bad assertion")] +) +@pytest.mark.usefixtures("fake_sign_assertion") +def test_edit_assertions_build_assertion_error( + error, + fake_assertion_service, + emitter, + mock_confirm_with_user, + write_text, + mocker, + tmp_path, +): + """Receive an error while building an assertion, then re-edit and post the assertion.""" + expected_assertion = b'{"test_field_1": "test-value-1-edited-edited-built", "test_field_2": "999"}-signed' + mock_post_assertion = mocker.spy(fake_assertion_service, "_post_assertion") mocker.patch.object( fake_assertion_service, - "_edit_yaml_file", - # make no changes to the fake assertion - return_value=FakeAssertion(test_field_1="test-value-1", test_field_2=0), + "_build_assertion", + side_effect=[ + error, + FakeAssertion( + test_field_1="test-value-1-edited-edited-built", test_field_2=999 + ), + ], ) + fake_assertion_service.setup() + fake_assertion_service.edit_assertion( + name="test-registry", account_id="test-account-id", key_name="test-key" + ) + assert mock_confirm_with_user.mock_calls == [ + mock.call("Do you wish to amend the fake assertion?") + ] + assert mock_post_assertion.mock_calls == [mock.call(expected_assertion)] + emitter.assert_trace(f"Signed assertion: {expected_assertion.decode()}") + emitter.assert_message("Success.") + assert not (tmp_path / "assertion-file").exists() + + +@pytest.mark.parametrize( + "write_text", + [ + [ + "test-field-1: test-value-1-edited-edited\ntest-field-2: 999", + "test-field-1: test-value-1-edited\ntest-field-2: 999", + ], + ], + indirect=True, +) +@pytest.mark.parametrize("mock_confirm_with_user", [True], indirect=True) +@pytest.mark.usefixtures("fake_sign_assertion") +def test_edit_assertions_sign_assertion_error( + fake_assertion_service, + emitter, + mock_confirm_with_user, + write_text, + mocker, + tmp_path, +): + """Receive an error while signing an assertion, then re-edit and post the assertion.""" + expected_assertion = b'{"test_field_1": "test-value-1-edited-edited-built", "test_field_2": "999"}-signed' + mock_post_assertion = mocker.spy(fake_assertion_service, "_post_assertion") + mocker.patch.object( + fake_assertion_service, + "_sign_assertion", + side_effect=[ + errors.SnapcraftAssertionError("bad assertion"), + expected_assertion, + ], + ) + + fake_assertion_service.setup() fake_assertion_service.edit_assertion( - name="test-registry", account_id="test-account-id" + name="test-registry", account_id="test-account-id", key_name="test-key" ) - emitter.assert_message("No changes made.") + assert mock_confirm_with_user.mock_calls == [ + mock.call("Do you wish to amend the fake assertion?") + ] + assert mock_post_assertion.mock_calls == [mock.call(expected_assertion)] + emitter.assert_message("Success.") + assert not (tmp_path / "assertion-file").exists() -@pytest.mark.parametrize("editor", [None, "faux-vi"]) @pytest.mark.parametrize( - "mock_subprocess_run", + "write_text", [ [ - textwrap.dedent( - """\ - test-field-1: test-value-1-UPDATED - test-field-2: 999 - """ - ), + "test-field-1: test-value-1-edited-edited\ntest-field-2: 999", + "test-field-1: test-value-1-edited\ntest-field-2: 999", ], ], indirect=True, ) @pytest.mark.parametrize("mock_confirm_with_user", [True], indirect=True) +@pytest.mark.parametrize( + "error", [FAKE_STORE_ERROR, errors.SnapcraftAssertionError("bad assertion")] +) +@pytest.mark.usefixtures("fake_sign_assertion") +def test_edit_assertions_post_assertion_error( + error, + fake_assertion_service, + emitter, + mock_confirm_with_user, + write_text, + mocker, + tmp_path, +): + """Receive an error while processing an assertion, then re-edit and post the assertion.""" + expected_first_assertion = ( + b'{"test_field_1": "test-value-1-edited-built", "test_field_2": "999"}-signed' + ) + expected_second_assertion = b'{"test_field_1": "test-value-1-edited-edited-built", "test_field_2": "999"}-signed' + mock_post_assertion = mocker.patch.object( + fake_assertion_service, "_post_assertion", side_effect=[error, None] + ) + + fake_assertion_service.setup() + fake_assertion_service.edit_assertion( + name="test-registry", account_id="test-account-id", key_name="test-key" + ) + + assert mock_confirm_with_user.mock_calls == [ + mock.call("Do you wish to amend the fake assertion?") + ] + assert mock_post_assertion.mock_calls == [ + mock.call(expected_first_assertion), + mock.call(expected_second_assertion), + ] + emitter.assert_trace(f"Signed assertion: {expected_second_assertion.decode()}") + emitter.assert_message("Success.") + assert not (tmp_path / "assertion-file").exists() + + +@pytest.mark.parametrize("editor", [None, "faux-vi"]) +@pytest.mark.parametrize( + "write_text", + [["test-field-1: test-value-1-edited\ntest-field-2: 999"]], + indirect=True, +) +@pytest.mark.parametrize("mock_confirm_with_user", [True], indirect=True) def test_edit_yaml_file( editor, fake_assertion_service, tmp_path, mock_confirm_with_user, - mock_subprocess_run, + write_text, monkeypatch, ): """Successfully edit a yaml file with the correct editor.""" @@ -271,50 +472,26 @@ def test_edit_yaml_file( edited_assertion = fake_assertion_service._edit_yaml_file(tmp_file) assert edited_assertion == FakeAssertion( - test_field_1="test-value-1-UPDATED", test_field_2=999 + test_field_1="test-value-1-edited", test_field_2=999 ) mock_confirm_with_user.assert_not_called() - assert mock_subprocess_run.mock_calls == [ - mock.call([expected_editor, tmp_file], check=True) - ] + assert write_text.mock_calls == [mock.call([expected_editor, tmp_file], check=True)] @pytest.mark.parametrize( - "mock_subprocess_run", + "write_text", [ pytest.param( [ - textwrap.dedent( - """\ - test-field-1: test-value-1-UPDATED - test-field-2: 999 - """ - ), - textwrap.dedent( - """\ - bad yaml {{ - test-field-1: test-value-1 - test-field-2: 0 - """ - ), + "test-field-1: test-value-1-edited\ntest-field-2: 999", + "bad yaml {{\ntest-field-1: test-value-1\ntest-field-2: 0", ], id="invalid yaml syntax", ), pytest.param( [ - textwrap.dedent( - """\ - test-field-1: test-value-1-UPDATED - test-field-2: 999 - """ - ), - textwrap.dedent( - """\ - extra-field: not-allowed - test-field-1: [wrong data type] - test-field-2: 0 - """ - ), + "test-field-1: test-value-1-edited\ntest-field-2: 999", + "extra-field: not-allowed\ntest-field-1: [wrong data type]\ntest-field-2: 0", ], id="invalid pydantic data", ), @@ -326,7 +503,7 @@ def test_edit_yaml_file_error_retry( fake_assertion_service, tmp_path, mock_confirm_with_user, - mock_subprocess_run, + write_text, ): """Edit a yaml file but encounter an error and retry.""" tmp_file = tmp_path / "assertion-file" @@ -335,30 +512,17 @@ def test_edit_yaml_file_error_retry( edited_assertion = fake_assertion_service._edit_yaml_file(tmp_file) assert edited_assertion == FakeAssertion( - test_field_1="test-value-1-UPDATED", test_field_2=999 + test_field_1="test-value-1-edited", test_field_2=999 ) assert mock_confirm_with_user.mock_calls == [ mock.call("Do you wish to amend the fake assertion?") ] - assert ( - mock_subprocess_run.mock_calls - == [mock.call(["faux-vi", tmp_file], check=True)] * 2 - ) + assert write_text.mock_calls == [mock.call(["faux-vi", tmp_file], check=True)] * 2 @pytest.mark.parametrize( - "mock_subprocess_run", - [ - [ - textwrap.dedent( - """\ - bad yaml {{ - test-field-1: test-value-1 - test-field-2: 0 - """ - ), - ], - ], + "write_text", + [["bad yaml {{\ntest-field-1: test-value-1\ntest-field-2: 0"]], indirect=True, ) @pytest.mark.parametrize("mock_confirm_with_user", [False], indirect=True) @@ -366,7 +530,7 @@ def test_edit_error_no_retry( fake_assertion_service, tmp_path, mock_confirm_with_user, - mock_subprocess_run, + write_text, ): """Edit a yaml file and encounter an error but do not retry.""" tmp_file = tmp_path / "assertion-file" @@ -378,6 +542,4 @@ def test_edit_error_no_retry( assert mock_confirm_with_user.mock_calls == [ mock.call("Do you wish to amend the fake assertion?") ] - assert mock_subprocess_run.mock_calls == [ - mock.call(["faux-vi", tmp_file], check=True) - ] + assert write_text.mock_calls == [mock.call(["faux-vi", tmp_file], check=True)] diff --git a/tests/unit/services/test_registries.py b/tests/unit/services/test_registries.py index f791cceb9a..2305f13413 100644 --- a/tests/unit/services/test_registries.py +++ b/tests/unit/services/test_registries.py @@ -17,8 +17,9 @@ """Tests for the registries service.""" import textwrap +from unittest import mock -from snapcraft.models import EditableRegistryAssertion +from snapcraft.models import EditableRegistryAssertion, RegistryAssertion def test_registries_service_type(registries_service): @@ -37,6 +38,24 @@ def test_get_assertions(registries_service): ) +def test_build_assertion(registries_service): + mock_assertion = mock.Mock(spec=RegistryAssertion) + + registries_service._build_assertion(mock_assertion) + + registries_service._store_client.build_registries.assert_called_once_with( + registries=mock_assertion + ) + + +def test_post_assertions(registries_service): + registries_service._post_assertion(b"test-assertion-data") + + registries_service._store_client.post_registries.assert_called_once_with( + registries_data=b"test-assertion-data" + ) + + def test_normalize_assertions_empty(registries_service, check): headers, registries = registries_service._normalize_assertions([]) @@ -71,7 +90,6 @@ def test_normalize_assertions(fake_registry_assertion, registries_service, check def test_generate_yaml_from_model(fake_registry_assertion, registries_service): assertion = fake_registry_assertion( - summary="test-summary", revision="10", views={ "wifi-setup": { @@ -102,7 +120,6 @@ def test_generate_yaml_from_model(fake_registry_assertion, registries_service): """\ account-id: test-account-id name: test-registry - # summary: test-summary # The revision for this registries set # revision: 10 views: @@ -129,3 +146,11 @@ def test_generate_yaml_from_model(fake_registry_assertion, registries_service): """ ) + + +def test_get_success_message(fake_registry_assertion, registries_service): + message = registries_service._get_success_message( + fake_registry_assertion(revision=10) + ) + + assert message == "Successfully created revision 10 for 'test-registry'." diff --git a/tests/unit/store/test_client.py b/tests/unit/store/test_client.py index 8a41d82fce..6fc14ae9f0 100644 --- a/tests/unit/store/test_client.py +++ b/tests/unit/store/test_client.py @@ -17,7 +17,7 @@ import json import textwrap import time -from unittest.mock import ANY, call +from unittest.mock import ANY, Mock, call import craft_store import pytest @@ -180,7 +180,7 @@ def list_registries_payload(): "account-id": "test-account-id", "authority-id": "test-authority-id", "body-length": "92", - "name": "test-registry", + "name": "test-registries", "revision": "9", "sign-key-sha3-384": "test-sign-key", "timestamp": "2024-01-01T10:20:30Z", @@ -203,6 +203,62 @@ def list_registries_payload(): } +@pytest.fixture +def build_registries_payload(): + return { + "account_id": "test-account-id", + "authority_id": "test-authority-id", + "name": "test-registries", + "revision": "10", + "views": { + "wifi-setup": { + "rules": [ + { + "request": "ssids", + "storage": "wifi.ssids", + "access": "read-write", + } + ] + } + }, + "body": '{\n "storage": {\n "schema": {\n "wifi": {\n "values": "any"\n }\n }\n }\n}', + "type": "registry", + "timestamp": "2024-01-01T10:20:30Z", + } + + +@pytest.fixture +def post_registries_payload(): + return { + "assertions": [ + { + "headers": { + "account-id": "test-account-id", + "authority-id": "test-authority-id", + "body-length": "92", + "name": "test-registries", + "revision": "10", + "sign-key-sha3-384": "test-key", + "timestamp": "2024-01-01T10:20:30Z", + "type": "registry", + "views": { + "wifi-setup": { + "rules": [ + { + "access": "read", + "request": "ssids", + "storage": "wifi.ssids", + } + ] + } + }, + }, + "body": '{\n "storage": {\n "schema": {\n "wifi": {\n "values": "any"\n }\n }\n }\n}', + } + ] + } + + #################### # User Agent Tests # #################### @@ -219,30 +275,6 @@ def test_useragent_linux(): ) -@pytest.mark.parametrize("testing_env", ("TRAVIS_TESTING", "AUTOPKGTEST_TMP")) -def test_useragent_linux_with_testing(monkeypatch, testing_env): - """Construct a user-agent as a patched Linux machine""" - monkeypatch.setenv(testing_env, "1") - os_platform = OSPlatform( - system="Arch Linux", release="5.10.10-arch1-1", machine="x86_64" - ) - - assert client.build_user_agent(version="7.1.0", os_platform=os_platform) == ( - "snapcraft/7.1.0 (testing) Arch Linux/5.10.10-arch1-1 (x86_64)" - ) - - -@pytest.mark.parametrize("testing_env", ("TRAVIS_TESTING", "AUTOPKGTEST_TMP")) -def test_useragent_windows_with_testing(monkeypatch, testing_env): - """Construct a user-agent as a patched Windows machine""" - monkeypatch.setenv(testing_env, "1") - os_platform = OSPlatform(system="Windows", release="10", machine="AMD64") - - assert client.build_user_agent(version="7.1.0", os_platform=os_platform) == ( - "snapcraft/7.1.0 (testing) Windows/10 (AMD64)" - ) - - ##################### # Store Environment # ##################### @@ -1091,7 +1123,7 @@ def test_list_revisions(fake_client, list_revisions_payload): @pytest.mark.parametrize("name", [None, "test-registry"]) def test_list_registries(name, fake_client, list_registries_payload, check): - """Test the registries endpoint.""" + """Test the list registries endpoint.""" fake_client.request.return_value = FakeResponse( status_code=200, content=json.dumps(list_registries_payload).encode() ) @@ -1122,7 +1154,7 @@ def test_list_registries(name, fake_client, list_registries_payload, check): def test_list_registries_empty(fake_client, check): - """Test the registries endpoint with no registries returned.""" + """Test the list registries endpoint with no registries returned.""" fake_client.request.return_value = FakeResponse( status_code=200, content=json.dumps({"assertions": []}).encode() ) @@ -1145,6 +1177,140 @@ def test_list_registries_empty(fake_client, check): ) +def test_list_registries_unmarshal_error(fake_client, list_registries_payload): + """Raise an error if the response cannot be unmarshalled.""" + list_registries_payload["assertions"][0]["headers"].pop("name") + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(list_registries_payload).encode() + ) + + with pytest.raises(errors.SnapcraftAssertionError) as raised: + client.StoreClientCLI().list_registries() + + assert str(raised.value) == "Received invalid registries set from the store" + assert raised.value.details == ( + "Bad registries set content:\n" + "- field 'name' required in top-level configuration" + ) + + +#################### +# Build Registries # +#################### + + +def test_build_registries(fake_client, build_registries_payload): + """Test the build registries endpoint.""" + mock_registries = Mock(spec=models.RegistryAssertion) + expected_registries = models.RegistryAssertion(**build_registries_payload) + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(build_registries_payload).encode() + ) + + registries_set = client.StoreClientCLI().build_registries( + registries=mock_registries + ) + + assert registries_set == expected_registries + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/api/v2/registries/build-assertion", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=mock_registries.marshal(), + ) + ] + + +def test_build_registries_unmarshal_error(fake_client, build_registries_payload): + """Raise an error if the response cannot be unmarshalled.""" + mock_registries = Mock(spec=models.RegistryAssertion) + build_registries_payload.pop("name") + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(build_registries_payload).encode() + ) + + with pytest.raises(errors.SnapcraftAssertionError) as raised: + client.StoreClientCLI().build_registries(registries=mock_registries) + + assert str(raised.value) == "Received invalid registries set from the store" + assert raised.value.details == ( + "Bad registries set content:\n" + "- field 'name' required in top-level configuration" + ) + + +################### +# Post Registries # +################### + + +def test_post_registries(fake_client, post_registries_payload): + """Test the post registries endpoint.""" + expected_registries = models.RegistryAssertion( + **post_registries_payload["assertions"][0]["headers"], + body=post_registries_payload["assertions"][0]["body"], + ) + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(post_registries_payload).encode() + ) + + registries_set = client.StoreClientCLI().post_registries( + registries_data=b"test-data" + ) + + assert registries_set == expected_registries + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/api/v2/registries", + headers={ + "Accept": "application/json", + "Content-Type": "application/x.ubuntu.assertion", + }, + data=b"test-data", + ) + ] + + +@pytest.mark.parametrize("num_assertions", [0, 2]) +def test_post_registries_wrong_payload_error( + num_assertions, fake_client, post_registries_payload +): + """Error if the wrong number of assertions are returned.""" + post_registries_payload["assertions"] = ( + post_registries_payload["assertions"] * num_assertions + ) + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(post_registries_payload).encode() + ) + + with pytest.raises(errors.SnapcraftAssertionError) as raised: + client.StoreClientCLI().post_registries(registries_data=b"test-data") + + assert str(raised.value) == "Received invalid registries set from the store" + + +def test_post_registries_unmarshal_error(fake_client, post_registries_payload): + """Raise an error if the response cannot be unmarshalled.""" + post_registries_payload["assertions"][0]["headers"].pop("name") + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(post_registries_payload).encode() + ) + + with pytest.raises(errors.SnapcraftAssertionError) as raised: + client.StoreClientCLI().post_registries(registries_data=b"test-data") + + assert str(raised.value) == "Received invalid registries set from the store" + assert raised.value.details == ( + "Bad registries set content:\n" + "- field 'name' required in top-level configuration" + ) + + ######################## # OnPremStoreClientCLI # ######################## diff --git a/tools/brew_install_from_source.py b/tools/brew_install_from_source.py index b4c3029fca..3c797464ed 100755 --- a/tools/brew_install_from_source.py +++ b/tools/brew_install_from_source.py @@ -46,10 +46,7 @@ def main(): def download_snapcraft_source(dest_dir): dest_file = os.path.join(dest_dir, "snapcraft-0.1.tar.gz") - branch_source = "https://github.com/{}/archive/{}.tar.gz".format( - os.environ.get("TRAVIS_PULL_REQUEST_SLUG") or "canonical/snapcraft", - os.environ.get("TRAVIS_PULL_REQUEST_BRANCH") or "main", - ) + branch_source = "https://github.com/canonical/snapcraft/archive/main.tar.gz" print("Downloading branch source from {}".format(branch_source)) urllib.request.urlretrieve(branch_source, dest_file) # noqa S310 return dest_file diff --git a/tools/collect_ppa_autopkgtests_results.py b/tools/collect_ppa_autopkgtests_results.py deleted file mode 100755 index e6a1cb8019..0000000000 --- a/tools/collect_ppa_autopkgtests_results.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017 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 . - -import argparse -import os -import subprocess -import tempfile - -ACTIVE_DISTROS = ("xenial", "artful", "bionic") - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("day", help="The day of the results, with format yyyymmdd") - args = parser.parse_args() - install_autopkgtest_results_formatter() - with tempfile.TemporaryDirectory(dir=os.environ.get("HOME")) as temp_dir: - clone_results_repo(temp_dir) - format_results(temp_dir, ACTIVE_DISTROS, args.day) - commit_and_push(temp_dir, args.day) - - -def install_autopkgtest_results_formatter(): - subprocess.check_call( - ["sudo", "snap", "install", "autopkgtest-results-formatter", "--edge"] - ) - - -def clone_results_repo(dest_dir): - subprocess.check_call( - ["git", "clone", "https://github.com/elopio/autopkgtest-results.git", dest_dir] - ) - - -def format_results(dest_dir, distros, day): - subprocess.check_call( - [ - "/snap/bin/autopkgtest-results-formatter", - "--destination", - dest_dir, - "--distros", - *distros, - "--day", - day, - ] - ) - - -def commit_and_push(repo_dir, day): - subprocess.check_call( - ["git", "config", "--global", "user.email", "u1test+m-o@canonical.com"] - ) - subprocess.check_call(["git", "config", "--global", "user.name", "snappy-m-o"]) - - subprocess.check_call(["git", "-C", repo_dir, "add", "--all"]) - subprocess.check_call( - [ - "git", - "-C", - repo_dir, - "commit", - "--message", - "Add the results for {}".format(day), - ] - ) - - subprocess.check_call( - [ - "git", - "-C", - repo_dir, - "push", - "https://{GH_TOKEN}@github.com/elopio/autopkgtest-results.git".format( - GH_TOKEN=os.environ.get("GH_TOKEN_PPA_AUTOPKGTEST_RESULTS") - ), - ] - ) - - -if __name__ == "__main__": - main() diff --git a/tools/freeze-requirements.sh b/tools/freeze-requirements.sh index 22723c420a..5f086892ee 100755 --- a/tools/freeze-requirements.sh +++ b/tools/freeze-requirements.sh @@ -5,7 +5,7 @@ requirements_fixups() { # Python apt library pinned to source. sed -i '/python-apt=*/d' "$req_file" - echo 'python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz; sys.platform == "linux"' >> "$req_file" + echo 'python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu1/python-apt_2.4.0ubuntu1.tar.xz ; sys.platform == "linux"' >> "$req_file" # https://bugs.launchpad.net/ubuntu/+source/python-pip/+bug/1635463 sed -i '/pkg[-_]resources==0.0.0/d' "$req_file" diff --git a/tools/retry_autopkgtest.sh b/tools/retry_autopkgtest.sh deleted file mode 100755 index 0d479acd7d..0000000000 --- a/tools/retry_autopkgtest.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -# -# Retry the autopkgtest run in a pull request. -# Arguments: -# pr: The identifier of the pull request to test. -# [release[:architecture[:test]] ...]: A list of the names of the Ubuntu releases and -# architectures and tests to run. By default, it will launch all the tests in -# `xenial:amd64`. If only the release name is passed as an argument, `amd64` -# will be used as the architecture. The possible autopkgtests are `integrationtests` -# and `snapstests`. -# -# Environment variables: -# SNAPCRAFT_AUTOPKGTEST_SECRET: The secret to authenticate the test execution. -# -# Examples: -# Run all the tests for pull request #123 in xenial amd64: -# ./tools/retry_autopkgtest.sh 123 -# Run all the tests for pull request #123 in xenial armhf: -# ./tools/retry_autopkgtest.sh 123 xenial:armhf -# Run all the tests for pull request #123 in xenial arm64, yakkety armhf and -# zesty amd64: -# ./tools/retry_autopkgtest.sh 123 xenial:arm64 yakkety:armhf zesty -# Run the integration tests for pull request #123 in xenial amd64: -# ./tools/retry_autopkgtest.sh 123 xenial:amd64:integrationtests - -if [ -z "${SNAPCRAFT_AUTOPKGTEST_SECRET}" ]; then - echo 'Set the secret to the environment variable SNAPCRAFT_AUTOPKGTEST_SECRET.' - exit 1 -fi - -if [ "$#" -lt 1 ]; then - echo "Usage: $0 [release[:architecture[:test]] ...]" - exit 1 -fi - -pr="$1" -shift -tests=("$@") -[ ${#tests[@]} -eq 0 ] && tests=('xenial:amd64') - -temp_dir="$(mktemp -d)" -trap 'rm -rf ${temp_dir}' EXIT - -# Download the retry script. -wget https://git.launchpad.net/autopkgtest-cloud/plain/tools/retry-github-test -O "${temp_dir}/retry-github-test" -chmod +x "${temp_dir}/retry-github-test" - -# Save the secret to a file. -echo "${SNAPCRAFT_AUTOPKGTEST_SECRET}" > "${temp_dir}/sec.txt" - -for testrun in "${tests[@]}"; do - IFS=':' read -r release architecture testsuite <<< "$testrun" - [ -z "$architecture" ] && architecture='amd64' - [ -n "$testsuite" ] && testname="&testname=${testsuite}" - echo "Launching tests for the ${release} release in the ${architecture} architecture..." - "${temp_dir}/retry-github-test" "https://api.github.com/repos/canonical/snapcraft/pulls/${pr}" "https://autopkgtest.ubuntu.com/request.cgi?release=${release}&arch=${architecture}&package=snapcraft${testname}&ppa=snappy-dev%2Fsnapcraft-daily" "${temp_dir}/sec.txt" -done diff --git a/tools/run_ppa_autopkgtests.py b/tools/run_ppa_autopkgtests.py deleted file mode 100755 index 8f5450c4a2..0000000000 --- a/tools/run_ppa_autopkgtests.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017 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 . - -import os -import subprocess -import sys -import tempfile - -from launchpadlib.launchpad import Launchpad - -ACTIVE_DISTROS = ("xenial", "artful", "bionic") -ACTIVE_ARCHITECTURES = ("amd64", "i386", "armhf", "arm64") - - -def main(): - try: - cookie_file_path = save_cookie() - for distro, architecture, version in snapcraft_ppa_packages(): - request_autopkgtest_execution( - cookie_file_path, distro, architecture, version - ) - finally: - os.remove(cookie_file_path) - - -def save_cookie(): - cookie_file = tempfile.NamedTemporaryFile(delete=False) - cookie_file.write( - "autopkgtest.ubuntu.com\tTRUE\t/\tTRUE\t0\tsession\t{}".format( - os.environ.get("SNAPCRAFT_AUTOPKGTEST_COOKIE") - ) - ) - cookie_file.close() - return cookie_file.name - - -def snapcraft_ppa_packages(): - launchpad = Launchpad.login_anonymously("snappy-m-o", "production") - ubuntu = launchpad.distributions["ubuntu"] - snapcraft_daily_ppa = launchpad.people["snappy-dev"].getPPAByName( - name="snapcraft-daily" - ) - - for distro in ACTIVE_DISTROS: - for architecture in ACTIVE_ARCHITECTURES: - distro_arch = ubuntu.getSeries(name_or_version=distro).getDistroArchSeries( - archtag=architecture - ) - for package in snapcraft_daily_ppa.getPublishedBinaries( - status="Published", - binary_name="snapcraft", - exact_match=True, - distro_arch_series=distro_arch, - ): - yield distro, architecture, str(package.binary_package_version) - - -def request_autopkgtest_execution(cookie_path, distro, architecture, version): - output = subprocess.check_output( - [ - "wget", - "-O-", - "--load-cookies", - cookie_path, - "https://autopkgtest.ubuntu.com/request.cgi?release={distro}&" - "arch={architecture}&package=snapcraft&" - "ppa=snappy-dev/snapcraft-daily&" - "trigger=snapcraft/{version}".format( - distro=distro, architecture=architecture, version=version - ), - ] - ) - if "Test request submitted" not in output: - sys.exit("Failed to request the autopkgtest") - - -if __name__ == "__main__": - main()