From b3dee09319665678cc545fa391f924660300666f Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 27 Jun 2023 11:38:34 -0400 Subject: [PATCH 01/14] type: workaround for pyright bug https://github.com/microsoft/pyright/issues/5394 --- snapcraft/commands/names.py | 3 ++- snapcraft/commands/status.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/snapcraft/commands/names.py b/snapcraft/commands/names.py index 2d477d7728..ea4374a5b3 100644 --- a/snapcraft/commands/names.py +++ b/snapcraft/commands/names.py @@ -132,12 +132,13 @@ class StoreNamesCommand(BaseCommand): def run(self, parsed_args): store_client = store.StoreClientCLI() snaps = store_client.get_names() + snaps.sort(key=operator.itemgetter(0)) if not snaps: emit.message("No registered snaps") else: tabulated_snaps = tabulate( - sorted(snaps, key=operator.itemgetter(0)), + snaps, headers=["Name", "Since", "Visibility", "Notes"], tablefmt="plain", ) diff --git a/snapcraft/commands/status.py b/snapcraft/commands/status.py index b1e8b6a7ff..d317645380 100644 --- a/snapcraft/commands/status.py +++ b/snapcraft/commands/status.py @@ -403,11 +403,11 @@ def run(self, parsed_args): ] for track in snap_channel_map.snap.tracks ] + track_table.sort(key=operator.itemgetter(2)) # Sort by "creation-date". emit.message( tabulate( - # Sort by "creation-date". - sorted(track_table, key=operator.itemgetter(2)), + track_table, headers=["Name", "Status", "Creation-Date", "Version-Pattern"], tablefmt="plain", ) From 4a7127fdd6c4b8a3abf590d78a1c1cf4df7c260b Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Thu, 29 Jun 2023 15:38:17 -0500 Subject: [PATCH 02/14] requirements: update craft-providers to 1.10.1 Signed-off-by: Callahan Kovacs --- requirements-devel.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-devel.txt b/requirements-devel.txt index 1ac83f9a80..8a6cf39296 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -15,7 +15,7 @@ craft-archives==0.0.3 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 -craft-providers==1.10.0 +craft-providers==1.10.1 craft-store==2.4.0 cryptography==40.0.2 Deprecated==1.2.13 diff --git a/requirements.txt b/requirements.txt index 5486ce4b69..fa4e2e69f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ craft-archives==0.0.3 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 -craft-providers==1.10.0 +craft-providers==1.10.1 craft-store==2.4.0 cryptography==40.0.2 Deprecated==1.2.13 From 1c44a58fcb02de3fb9e0351ea87034f56233fa99 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 29 Jun 2023 21:37:50 -0400 Subject: [PATCH 03/14] store: add missing case for list-keys (#4245) --- snapcraft_legacy/_store.py | 32 +++++--- tests/legacy/unit/commands/test_list_keys.py | 35 +++++---- tests/legacy/unit/store/test_keys.py | 78 ++++++++++++++++++++ 3 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 tests/legacy/unit/store/test_keys.py diff --git a/snapcraft_legacy/_store.py b/snapcraft_legacy/_store.py index 3aa074b8c2..5b6a8969e9 100644 --- a/snapcraft_legacy/_store.py +++ b/snapcraft_legacy/_store.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2016-2022 Canonical Ltd +# Copyright 2016-2023 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 @@ -435,11 +435,11 @@ def list_keys(): """Lists keys available to sign assertions.""" keys = list(_get_usable_keys()) account_info = StoreClientCLI().get_account_information() - enabled_keys = { + enabled_keys = [ account_key["public-key-sha3-384"] for account_key in account_info["account_keys"] - } - if keys and enabled_keys: + ] + if keys: tabulated_keys = tabulate( [ ( @@ -453,19 +453,29 @@ def list_keys(): headers=["", "Name", "SHA3-384 fingerprint", ""], tablefmt="plain", ) + print( + "The following keys are available on this system:" + ) print(tabulated_keys) - elif not keys and enabled_keys: - registered_keys = "\n".join([f"- {key}" for key in enabled_keys]) + else: print( "No keys have been created on this system. " - " See 'snapcraft create-key --help' to create a key.\n" - "The following SHA3-384 key fingerprints have been registered " - f"but are not available on this system:\n{registered_keys}" + "See 'snapcraft create-key --help' to create a key." ) + if enabled_keys: + local_hashes = {key["sha3-384"] for key in keys} + registered_keys = "\n".join( + (f"- {key}" for key in enabled_keys if key not in local_hashes) + ) + if registered_keys: + print( + "The following SHA3-384 key fingerprints have been registered " + f"but are not available on this system:\n{registered_keys}" + ) else: print( - "No keys have been registered." - " See 'snapcraft register-key --help' to register a key." + "No keys have been registered with this account. " + "See 'snapcraft register-key --help' to register a key." ) diff --git a/tests/legacy/unit/commands/test_list_keys.py b/tests/legacy/unit/commands/test_list_keys.py index 9e0e23b835..6558f4ba10 100644 --- a/tests/legacy/unit/commands/test_list_keys.py +++ b/tests/legacy/unit/commands/test_list_keys.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2016-2019 Canonical Ltd +# Copyright (C) 2016-2023 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 @@ -59,16 +59,14 @@ def test_list_keys_successfully(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( + Equals( dedent( - """\ - Name SHA3-384 fingerprint - * default {default_sha3_384} - - another {another_sha3_384} (not registered) - """ - ).format( - default_sha3_384=get_sample_key("default")["sha3-384"], - another_sha3_384=get_sample_key("another")["sha3-384"], + f"""\ + The following keys are available on this system: + Name SHA3-384 fingerprint + * default {get_sample_key("default")["sha3-384"]} + - another {get_sample_key("another")["sha3-384"]} (not registered) + """ ) ), ) @@ -95,10 +93,10 @@ def test_list_keys_no_keys_on_system(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( + Equals( dedent( """\ - No keys have been created on this system. See 'snapcraft create-key --help' to create a key. + No keys have been created on this system. See 'snapcraft create-key --help' to create a key. The following SHA3-384 key fingerprints have been registered but are not available on this system: - vdEeQvRxmZ26npJCFaGnl-VfGz0lU2jZZkWp_s7E-RxVCNtH2_mtjcxq2NkDKkIp """ @@ -132,8 +130,15 @@ def test_list_keys_without_registered(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat( result.output, - Contains( - "No keys have been registered. " - "See 'snapcraft register-key --help' to register a key." + Equals( + dedent( + f"""\ + The following keys are available on this system: + Name SHA3-384 fingerprint + - default {get_sample_key("default")["sha3-384"]} (not registered) + - another {get_sample_key("another")["sha3-384"]} (not registered) + No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. + """ + ) ), ) diff --git a/tests/legacy/unit/store/test_keys.py b/tests/legacy/unit/store/test_keys.py new file mode 100644 index 0000000000..f818dee658 --- /dev/null +++ b/tests/legacy/unit/store/test_keys.py @@ -0,0 +1,78 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 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 . +from unittest import mock + +import pytest + +from snapcraft_legacy import _store + +NO_KEYS_OUTPUT = """\ +No keys have been created on this system. See 'snapcraft create-key --help' to create a key. +No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. +""" +ONLY_CREATED_KEYS_OUTPUT = """\ +The following keys are available on this system: + Name SHA3-384 fingerprint +- ctrl ModelM (not registered) +- alt Dvorak (not registered) +No keys have been registered with this account. See 'snapcraft register-key --help' to register a key. +""" +UNAVAILABLE_KEYS_OUTPUT = """\ +No keys have been created on this system. See 'snapcraft create-key --help' to create a key. +The following SHA3-384 key fingerprints have been registered but are not available on this system: +- Bach +- ModelM +""" +SOME_KEYS_OVERLAP_OUTPUT = """\ +The following keys are available on this system: + Name SHA3-384 fingerprint +* ctrl ModelM +- alt Dvorak (not registered) +The following SHA3-384 key fingerprints have been registered but are not available on this system: +- Bach +""" +LOCAL_KEYS = [ + {"name": "ctrl", "sha3-384": "ModelM"}, + {"name": "alt", "sha3-384": "Dvorak"}, +] +REMOTE_KEYS = [ + {"public-key-sha3-384": "Bach"}, + {"public-key-sha3-384": "ModelM"}, +] +NO_KEYS = [] + + +@pytest.mark.parametrize( + ("usable_keys", "account_keys", "stdout"), + [ + (NO_KEYS, NO_KEYS, NO_KEYS_OUTPUT), + (LOCAL_KEYS, NO_KEYS, ONLY_CREATED_KEYS_OUTPUT), + (NO_KEYS, REMOTE_KEYS, UNAVAILABLE_KEYS_OUTPUT), + (LOCAL_KEYS, REMOTE_KEYS, SOME_KEYS_OVERLAP_OUTPUT), + ], +) +def test_list_keys(monkeypatch, capsys, usable_keys, account_keys, stdout): + monkeypatch.setattr(_store, "_get_usable_keys", lambda: usable_keys) + mock_client = mock.Mock(spec_set=_store.StoreClientCLI) + mock_client.get_account_information.return_value = {"account_keys": account_keys} + monkeypatch.setattr(_store, "StoreClientCLI", lambda: mock_client) + + _store.list_keys() + + out, err = capsys.readouterr() + + assert out == stdout + assert err == "" From aa3d5d67f3f42a9eacb8cb16448a739597ccfd7d Mon Sep 17 00:00:00 2001 From: Sergio Schvezov Date: Thu, 29 Jun 2023 21:12:13 -0300 Subject: [PATCH 04/14] spread: disable godeps tests godeps can not longer build due to https://github.com/golang/go/issues/57051 Instead of removing the godeps feature, disable the tests in case the upstream fixes the problem. --- spread.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spread.yaml b/spread.yaml index e874590157..7a43d57869 100644 --- a/spread.yaml +++ b/spread.yaml @@ -416,10 +416,12 @@ suites: summary: tests of snapcraft's Go plugin systems: - ubuntu-18.04* - tests/spread/plugins/v1/godeps/: - summary: tests of snapcraft's Godeps plugin - systems: - - ubuntu-18.04* + # godeps can no longer build due to: + # https://github.com/golang/go/issues/57051 + # tests/spread/plugins/v1/godeps/: + # summary: tests of snapcraft's Godeps plugin + # systems: + # - ubuntu-18.04* tests/spread/plugins/v1/gradle/: summary: tests of snapcraft's Gradle plugin systems: From 5134d01e09edb1448a39ca3f57a552a0762d8d37 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Fri, 26 May 2023 11:56:18 -0300 Subject: [PATCH 05/14] requirements: update craft-archives (#4174) --- requirements-devel.txt | 2 +- requirements.txt | 2 +- setup.py | 2 +- snapcraft_legacy/plugins/v1/catkin.py | 1 + snapcraft_legacy/plugins/v1/colcon.py | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements-devel.txt b/requirements-devel.txt index 8a6cf39296..b1df5f1cf6 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.3 codespell==2.2.4 colorama==0.4.6 coverage==7.2.5 -craft-archives==0.0.3 +craft-archives==1.0.0 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 diff --git a/requirements.txt b/requirements.txt index fa4e2e69f7..3c21ea90dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.15.1 chardet==5.1.0 charset-normalizer==3.1.0 click==8.1.3 -craft-archives==0.0.3 +craft-archives==1.0.0 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 diff --git a/setup.py b/setup.py index 768587f64c..b893e30c37 100755 --- a/setup.py +++ b/setup.py @@ -99,7 +99,7 @@ def recursive_data_files(directory, install_directory): install_requires = [ "attrs", "click", - "craft-archives==0.0.3", + "craft-archives", "craft-cli", "craft-grammar", "craft-parts", diff --git a/snapcraft_legacy/plugins/v1/catkin.py b/snapcraft_legacy/plugins/v1/catkin.py index 73fe9de2e1..90d3ea9ebd 100644 --- a/snapcraft_legacy/plugins/v1/catkin.py +++ b/snapcraft_legacy/plugins/v1/catkin.py @@ -282,6 +282,7 @@ def get_required_package_repositories(self) -> List[PackageRepository]: return [ PackageRepositoryApt( + type="apt", formats=["deb"], components=["main"], key_id="C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654", diff --git a/snapcraft_legacy/plugins/v1/colcon.py b/snapcraft_legacy/plugins/v1/colcon.py index 73737be1ce..038a5268b2 100644 --- a/snapcraft_legacy/plugins/v1/colcon.py +++ b/snapcraft_legacy/plugins/v1/colcon.py @@ -231,6 +231,7 @@ def get_required_package_repositories(cls) -> List[PackageRepository]: codename = os_release.OsRelease().version_codename() return [ PackageRepositoryApt( + type="apt", formats=["deb"], components=["main"], key_id="C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654", From 586a9fe921deb80783672aecdad47a14f00b7493 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Tue, 27 Jun 2023 17:18:07 -0300 Subject: [PATCH 06/14] requirements: bump craft-archives to fix regression The new version supports asset files that contain more than one key (for whatever reason). This is more in line with the behavior done by the previous implementation (via apt-key). Closes #4224 --- requirements-devel.txt | 2 +- requirements.txt | 2 +- .../core22/package-repositories/task.yaml | 1 + .../test-multi-keys/snap/keys/9E61EF26.asc | 239 ++++++++++++++++++ .../test-multi-keys/snap/snapcraft.yaml | 38 +++ 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc create mode 100644 tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml diff --git a/requirements-devel.txt b/requirements-devel.txt index b1df5f1cf6..b5c9297996 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.3 codespell==2.2.4 colorama==0.4.6 coverage==7.2.5 -craft-archives==1.0.0 +craft-archives==1.1.1 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 diff --git a/requirements.txt b/requirements.txt index 3c21ea90dc..7a0a3ed4d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.15.1 chardet==5.1.0 charset-normalizer==3.1.0 click==8.1.3 -craft-archives==1.0.0 +craft-archives==1.1.1 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 diff --git a/tests/spread/core22/package-repositories/task.yaml b/tests/spread/core22/package-repositories/task.yaml index c8eff1af2f..c147fa0517 100644 --- a/tests/spread/core22/package-repositories/task.yaml +++ b/tests/spread/core22/package-repositories/task.yaml @@ -6,6 +6,7 @@ environment: SNAP/test_apt_keyserver: test-apt-keyserver SNAP/test_apt_ppa: test-apt-ppa SNAP/test_pin: test-pin + SNAP/test_multi_keys: test-multi-keys SNAPCRAFT_BUILD_ENVIRONMENT: "" restore: | diff --git a/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc b/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc new file mode 100644 index 0000000000..be4fa1d55f --- /dev/null +++ b/tests/spread/core22/package-repositories/test-multi-keys/snap/keys/9E61EF26.asc @@ -0,0 +1,239 @@ +# NOTE: this keyring has expired keys and subkeys. Lines with "pub:e:" are expired +# keys, and lines with "sub:e:" are expired subkeys. python-gnupg returns the +# fingerprints for all primary keys, expired or not: +# pub:e:4096:1:B8F999C007BB6C57:1360109177:1549910347::-:::sc::::::23::0: +# fpr:::::::::8735F5AF62A99A628EC13377B8F999C007BB6C57: +# uid:e::::1455302347::A8FC88656336852AD4301DF059CEE6134FD37C21::Puppet Labs Nightly Build Key (Puppet Labs Nightly Build Key) ::::::::::0: +# uid:e::::1455302347::4EF2A82F1FF355343885012A832C628E1A4F73A8::Puppet Labs Nightly Build Key (Puppet Labs Nightly Build Key) ::::::::::0: +# sub:e:4096:1:AE8282E5A5FC3E74:1360109177:1549910293:::::e::::::23: +# fpr:::::::::F838D657CCAF0E4A6375B0E9AE8282E5A5FC3E74: +# gpg: key 7F438280EF8D349F: 8 signatures not checked due to missing keys +# pub:e:4096:1:7F438280EF8D349F:1471554366:1629234366::-:::sc::::::23::0: +# fpr:::::::::6F6B15509CF8E59E6E469F327F438280EF8D349F: +# uid:e::::1471554366::B648B946D1E13EEA5F4081D8FE5CF4D001200BC7::Puppet, Inc. Release Key (Puppet, Inc. Release Key) ::::::::::0: +# sub:e:4096:1:A2D80E04656674AE:1471554366:1629234366:::::e::::::23: +# fpr:::::::::07F5ABF8FE84BC3736D2AAD3A2D80E04656674AE: +# pub:-:4096:1:4528B6CD9E61EF26:1554759562:1743975562::-:::scESC::::::23::0: +# fpr:::::::::D6811ED3ADEEB8441AF5AA8F4528B6CD9E61EF26: +# uid:-::::1554759562::B648B946D1E13EEA5F4081D8FE5CF4D001200BC7::Puppet, Inc. Release Key (Puppet, Inc. Release Key) ::::::::::0: +# sub:-:4096:1:F230A24E9F057A83:1554759562:1743975562:::::e::::::23: +# fpr:::::::::90A29D0A6576E2CA185AED3EF230A24E9F057A83: +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=h0KC +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml new file mode 100644 index 0000000000..19fe27377d --- /dev/null +++ b/tests/spread/core22/package-repositories/test-multi-keys/snap/snapcraft.yaml @@ -0,0 +1,38 @@ +name: test-multi-keys +version: '1.0' +summary: test +description: test installing a package repository with an asset file with multiple keys +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + build-packages: + - test-ppa + stage-packages: + - test-ppa + - puppet-tools-release # Comes from the Puppet repo + override-build: | + craftctl default + # This file comes from the puppet-tools-release package. + test -f ${CRAFT_PART_INSTALL}/usr/share/doc/puppet-tools-release/bill-of-materials + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + formats: [deb, deb-src] + components: [main] + suites: [focal] + key-id: 78E1918602959B9C59103100F1831DDAFC42E99D + url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu + - type: apt # puppet bolt + components: [puppet-tools] + suites: [focal] + url: http://apt.puppet.com + # Asset 9E61EF26.asc has multiple keys inside + key-id: D6811ED3ADEEB8441AF5AA8F4528B6CD9E61EF26 From b6bd029c054e1a0df06cc96b4e89a0ac6fa70d66 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Fri, 30 Jun 2023 15:38:15 -0300 Subject: [PATCH 07/14] lint: ignore .asc files in codespell --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 13464c92c6..12aef2f795 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [codespell] ignore-words-list = buildd,crate,keyserver,comandos,ro,astroid -skip = waf,*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,*.so,changelog,.git,.hg,.mypy_cache,.tox,.venv,venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache,.ruff_cache +skip = waf,*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,*.so,changelog,.git,.hg,.mypy_cache,.tox,.venv,venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache,.ruff_cache,*.asc quiet-level = 4 [flake8] From cfacbc54b9200aecd72761f7a066cfa82e898fa5 Mon Sep 17 00:00:00 2001 From: Callahan Date: Tue, 11 Jul 2023 19:46:16 -0500 Subject: [PATCH 08/14] extension: remove extra kde-neon build envvars (#4242) remove extra build environment variables that are causing build failures. Co-authored-by: Scarlett Gately Moore --- snapcraft/extensions/kde_neon.py | 30 ------------ tests/unit/extensions/test_kde_neon.py | 67 -------------------------- 2 files changed, 97 deletions(-) diff --git a/snapcraft/extensions/kde_neon.py b/snapcraft/extensions/kde_neon.py index 442e897472..7daf72537f 100644 --- a/snapcraft/extensions/kde_neon.py +++ b/snapcraft/extensions/kde_neon.py @@ -177,36 +177,6 @@ def get_part_snippet(self) -> Dict[str, Any]: ], ), }, - { - "LD_LIBRARY_PATH": prepend_to_env( - "LD_LIBRARY_PATH", - [ - f"/snap/{sdk_snap}/current/lib/$CRAFT_ARCH_TRIPLET", - f"/snap/{sdk_snap}/current/usr/lib/$CRAFT_ARCH_TRIPLET", - f"/snap/{sdk_snap}/current/usr/lib", - f"/snap/{sdk_snap}/current/usr/lib/vala-current", - f"/snap/{sdk_snap}/current/usr/lib/$CRAFT_ARCH_TRIPLET/pulseaudio", - ], - ), - }, - { - "PKG_CONFIG_PATH": prepend_to_env( - "PKG_CONFIG_PATH", - [ - f"/snap/{sdk_snap}/current/usr/lib/$CRAFT_ARCH_TRIPLET/pkgconfig", - f"/snap/{sdk_snap}/current/usr/lib/pkgconfig", - f"/snap/{sdk_snap}/current/usr/share/pkgconfig", - ], - ), - }, - { - "ACLOCAL_PATH": prepend_to_env( - "ACLOCAL_PATH", - [ - f"/snap/{sdk_snap}/current/usr/share/aclocal", - ], - ), - }, { "SNAPCRAFT_CMAKE_ARGS": prepend_to_env( "SNAPCRAFT_CMAKE_ARGS", diff --git a/tests/unit/extensions/test_kde_neon.py b/tests/unit/extensions/test_kde_neon.py index 969d6e5311..6af25e556f 100644 --- a/tests/unit/extensions/test_kde_neon.py +++ b/tests/unit/extensions/test_kde_neon.py @@ -181,40 +181,6 @@ def assert_get_part_snippet(kde_neon_instance): "/current/usr/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" ) }, - { - "LD_LIBRARY_PATH": ":".join( - [ - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "lib/$CRAFT_ARCH_TRIPLET", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/usr/lib", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/vala-current", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET/pulseaudio", - ] - ) - + "${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - }, - { - "PKG_CONFIG_PATH": ( - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET/pkgconfig:" - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/pkgconfig:" - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/share/pkgconfig" - ) - + "${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" - }, - { - "ACLOCAL_PATH": ( - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/share/aclocal" - ) - + "${ACLOCAL_PATH:+:$ACLOCAL_PATH}" - }, { "SNAPCRAFT_CMAKE_ARGS": ( "-DCMAKE_FIND_ROOT_PATH=" @@ -241,39 +207,6 @@ def test_get_part_snippet_with_external_sdk(kde_neon_extension_with_build_snap): "/current/usr/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" ) }, - { - "LD_LIBRARY_PATH": ":".join( - [ - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "lib/$CRAFT_ARCH_TRIPLET", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/usr/lib", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/vala-current", - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET/pulseaudio", - ] - ) - + "${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - }, - { - "PKG_CONFIG_PATH": ( - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/$CRAFT_ARCH_TRIPLET/pkgconfig:" - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/lib/pkgconfig:" - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" - "usr/share/pkgconfig" - ) - + "${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" - }, - { - "ACLOCAL_PATH": ( - "/snap/kf5-5-105-qt-5-15-9-core22-sdk/current/" "usr/share/aclocal" - ) - + "${ACLOCAL_PATH:+:$ACLOCAL_PATH}" - }, { "SNAPCRAFT_CMAKE_ARGS": ( "-DCMAKE_FIND_ROOT_PATH=" From 833b0f213fae37c3dd53939b41079bbb6c08020d Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Thu, 13 Jul 2023 11:50:56 -0300 Subject: [PATCH 09/14] spread: add a test for the "python + classic" tutorial (#4269) This commit adds a spread test to check the "Set up classic confinement for a Python project" tutorial. As an overview, the test: - runs specifically on Ubuntu 20.04 systems; - clones the test repo from snapcraft-docs/python-ctypes-example; - builds & installs the snap from the test repo; - runs the snap on the 20.04 system, to ensure the patchelf fix is working in the presence of the classic confinement. Co-authored-by: Callahan --- .../core22/patchelf/classic-python/task.yaml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/spread/core22/patchelf/classic-python/task.yaml diff --git a/tests/spread/core22/patchelf/classic-python/task.yaml b/tests/spread/core22/patchelf/classic-python/task.yaml new file mode 100644 index 0000000000..e28f835b2e --- /dev/null +++ b/tests/spread/core22/patchelf/classic-python/task.yaml @@ -0,0 +1,41 @@ +summary: Build and run a Python-based core22 classic snap + +# To ensure the patchelf fixes are correct, we run this test on focal systems. +systems: + - ubuntu-20.04 + - ubuntu-20.04-64 + - ubuntu-20.04-amd64 + - ubuntu-20.04-arm64 + - ubuntu-20.04-armhf + - ubuntu-20.04-s390x + - ubuntu-20.04-ppc64el + +prepare: | + # Clone the snapcraft-docs/python-ctypes-example + git clone https://github.com/snapcraft-docs/python-ctypes-example.git + cd python-ctypes-example + # A known "good" commit from "main" at the time of writing this test + git checkout 31939ef68d8c383b9202f2588a704b3271bae009 + + # Replace the existing snap command with a call to the provisioned python3 + sed -i 's|command: bin/test-ctypes.py|command: bin/python3|' snap/snapcraft.yaml + +execute: | + cd python-ctypes-example + + # Build the core22 snap + unset SNAPCRAFT_BUILD_ENVIRONMENT + snapcraft --use-lxd + + # Install the new snap + sudo snap install --classic --dangerous example-python-ctypes*.snap + + # Run the snap's command; success means patchelf correctly linked the Python + # interpreter to core22's libc. Failure would output things like: + # version `GLIBC_2.35' not found (required by /snap/example-python-ctypes/x1/bin/python3) + example-python-ctypes -c "import ctypes; print(ctypes.__file__)" | MATCH "/snap/example-python-ctypes/" + +restore: | + cd python-ctypes-example + snapcraft clean + rm -f ./*.snap From de56fe6a10e096ad3feb1620faffe39a520a9c37 Mon Sep 17 00:00:00 2001 From: Sergio Schvezov Date: Thu, 13 Jul 2023 15:35:31 -0300 Subject: [PATCH 10/14] meta: write out metadata links (#4275) Signed-off-by: Sergio Schvezov --- snapcraft/meta/snap_yaml.py | 41 ++++- tests/spread/general/metadata-links/task.yaml | 8 + tests/unit/meta/test_snap_yaml.py | 162 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py index c1915f4915..618984b1ce 100644 --- a/snapcraft/meta/snap_yaml.py +++ b/snapcraft/meta/snap_yaml.py @@ -26,7 +26,7 @@ from pydantic_yaml import YamlModel from snapcraft import errors -from snapcraft.projects import App, Project +from snapcraft.projects import App, Project, UniqueStrList from snapcraft.utils import get_ld_library_paths, process_version @@ -176,6 +176,41 @@ def get_content_dirs(self, installed_path: Path) -> Set[Path]: return content_dirs +class Links(_SnapMetadataModel): + """Metadata links used in snaps.""" + + contact: Optional[UniqueStrList] + donation: Optional[UniqueStrList] + issues: Optional[UniqueStrList] + source_code: Optional[UniqueStrList] + website: Optional[UniqueStrList] + + @staticmethod + def _normalize_value( + value: Optional[Union[str, UniqueStrList]] + ) -> Optional[List[str]]: + if isinstance(value, str): + value = [value] + return value + + @classmethod + def from_project(cls, project: Project) -> "Links": + """Create Links from a Project.""" + return cls( + contact=cls._normalize_value(project.contact), + donation=cls._normalize_value(project.donation), + issues=cls._normalize_value(project.issues), + source_code=cls._normalize_value(project.source_code), + website=cls._normalize_value(project.website), + ) + + def __bool__(self) -> bool: + """Return True if any of the Links attributes are set.""" + return any( + [self.contact, self.donation, self.issues, self.source_code, self.website] + ) + + class SnapMetadata(_SnapMetadataModel): """The snap.yaml model. @@ -210,6 +245,7 @@ class Config: layout: Optional[Dict[str, Dict[str, str]]] system_usernames: Optional[Dict[str, Any]] provenance: Optional[str] + links: Optional[Links] @classmethod def unmarshal(cls, data: Dict[str, Any]) -> "SnapMetadata": @@ -403,6 +439,8 @@ def write(project: Project, prime_dir: Path, *, arch: str): # project provided assumes and computed assumes total_assumes = sorted(project.assumes + list(assumes)) + links = Links.from_project(project) + snap_metadata = SnapMetadata( name=project.name, title=project.title, @@ -425,6 +463,7 @@ def write(project: Project, prime_dir: Path, *, arch: str): layout=project.layout, system_usernames=project.system_usernames, provenance=project.provenance, + links=links if links else None, ) if project.passthrough: for name, value in project.passthrough.items(): diff --git a/tests/spread/general/metadata-links/task.yaml b/tests/spread/general/metadata-links/task.yaml index 47730acec2..41b2976613 100644 --- a/tests/spread/general/metadata-links/task.yaml +++ b/tests/spread/general/metadata-links/task.yaml @@ -6,10 +6,18 @@ environment: prepare: | snap install yq + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + set_base snapcraft.yaml + restore: | snapcraft clean rm -rf ./*.snap + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml snapcraft.yaml + execute: | # Create a snap to trigger `snap pack`. snapcraft diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py index d04a8cd408..b5148863f5 100644 --- a/tests/unit/meta/test_snap_yaml.py +++ b/tests/unit/meta/test_snap_yaml.py @@ -124,6 +124,111 @@ def test_assumes(simple_project, new_dir): ) +def test_links_scalars(simple_project, new_dir): + snap_yaml.write( + simple_project( + contact="me@acme.com", + issues="https://hubhub.com/issues", + donation="https://moneyfornothing.com", + source_code="https://closed.acme.com", + website="https://acme.com", + ), + prime_dir=Path(new_dir), + arch="amd64", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: test-description + architectures: + - amd64 + base: core22 + apps: + app1: + command: bin/mytest + confinement: strict + grade: stable + 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 + links: + contact: + - me@acme.com + donation: + - https://moneyfornothing.com + issues: + - https://hubhub.com/issues + source-code: + - https://closed.acme.com + website: + - https://acme.com + """ + ) + + +def test_links_lists(simple_project, new_dir): + snap_yaml.write( + simple_project( + contact=[ + "me@acme.com", + "you@acme.com", + ], + issues=[ + "https://hubhub.com/issues", + "https://corner.com/issues", + ], + donation=["https://moneyfornothing.com", "https://prince.com"], + source_code="https://closed.acme.com", + website="https://acme.com", + ), + prime_dir=Path(new_dir), + arch="amd64", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: test-description + architectures: + - amd64 + base: core22 + apps: + app1: + command: bin/mytest + confinement: strict + grade: stable + 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 + links: + contact: + - me@acme.com + - you@acme.com + donation: + - https://moneyfornothing.com + - https://prince.com + issues: + - https://hubhub.com/issues + - https://corner.com/issues + source-code: + - https://closed.acme.com + website: + - https://acme.com + """ + ) + + @pytest.fixture def complex_project(): snapcraft_yaml = textwrap.dedent( @@ -1025,3 +1130,60 @@ def test_architectures_all(simple_project, new_dir): "${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}:" "$SNAP/lib:$SNAP/usr/lib\n" ) in content + + +############## +# Test Links # +############## + + +def test_links_for_scalars(simple_project): + project = simple_project( + contact="me@acme.com", + issues="https://hubhub.com/issues", + donation="https://moneyfornothing.com", + source_code="https://closed.acme.com", + website="https://acme.com", + ) + + links = snap_yaml.Links.from_project(project) + + assert links.contact == [project.contact] + assert links.issues == [project.issues] + assert links.donation == [project.donation] + assert links.source_code == [project.source_code] + assert links.website == [project.website] + + assert bool(links) is True + + +def test_links_for_lists(simple_project): + project = simple_project( + contact=[ + "me@acme.com", + "you@acme.com", + ], + issues=[ + "https://hubhub.com/issues", + "https://corner.com/issues", + ], + donation=["https://moneyfornothing.com", "https://prince.com"], + ) + + links = snap_yaml.Links.from_project(project) + + assert links.contact == project.contact + assert links.issues == project.issues + assert links.donation == project.donation + assert links.source_code is None + assert links.website is None + + assert bool(links) is True + + +def test_no_links(simple_project): + project = simple_project() + + links = snap_yaml.Links.from_project(project) + + assert bool(links) is False From 7e6e31733bf795d06b3dd2b8989277a65f2fcb68 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Fri, 14 Jul 2023 11:10:31 -0300 Subject: [PATCH 11/14] requirements: bump craft-archives (#4277) Version 1.1.2 allows local package repositories (those with scheme `url: file:///...`) again. --- requirements-devel.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-devel.txt b/requirements-devel.txt index b5c9297996..5e78ee1d64 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.3 codespell==2.2.4 colorama==0.4.6 coverage==7.2.5 -craft-archives==1.1.1 +craft-archives==1.1.2 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 diff --git a/requirements.txt b/requirements.txt index 7a0a3ed4d4..66627af67f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.15.1 chardet==5.1.0 charset-normalizer==3.1.0 click==8.1.3 -craft-archives==1.1.1 +craft-archives==1.1.2 craft-cli==1.2.0 craft-grammar==1.1.1 craft-parts==1.19.6 From 4d7eab2deee126d76d166d717ba442896e3b5579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Suliga?= <1270737+suligap@users.noreply.github.com> Date: Fri, 14 Jul 2023 19:47:34 +0200 Subject: [PATCH 12/14] commands: fix missing built_at store upload option (#4261) snapcraft-started-at is a manifest field. This suggests that upload never sent built_at because it was trying to extract it from meta/snap.yaml instead of snap/manifest.yaml. The unused before this change test file test-snap-with-started-at.snap was updated accordingly: - snapcraft-started-at field was removed from meta/snap.yaml, - and a new snap/manifest.yaml was added. The change was tested against the Store. Co-authored-by: Sergio Schvezov Co-authored-by: Callahan --- snapcraft/commands/upload.py | 6 ++- snapcraft_legacy/_store.py | 14 +++-- .../data/test-snap-with-started-at.snap | Bin 4096 -> 4096 bytes tests/legacy/unit/commands/test_sign_build.py | 16 +++--- tests/unit/commands/test_upload.py | 48 ++++++++++++++++++ 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/snapcraft/commands/upload.py b/snapcraft/commands/upload.py index e0c8bfcffb..eeab23168a 100644 --- a/snapcraft/commands/upload.py +++ b/snapcraft/commands/upload.py @@ -80,9 +80,11 @@ def run(self, parsed_args): client = store.StoreClientCLI() - snap_yaml = get_data_from_snap_file(snap_file) + snap_yaml, manifest_yaml = get_data_from_snap_file(snap_file) snap_name = snap_yaml["name"] - built_at = snap_yaml.get("snapcraft-started-at") + built_at = None + if manifest_yaml: + built_at = manifest_yaml.get("snapcraft-started-at") client.verify_upload(snap_name=snap_name) diff --git a/snapcraft_legacy/_store.py b/snapcraft_legacy/_store.py index 5b6a8969e9..8f264e687e 100644 --- a/snapcraft_legacy/_store.py +++ b/snapcraft_legacy/_store.py @@ -52,6 +52,7 @@ def get_data_from_snap_file(snap_path): + manifest_yaml = None with tempfile.TemporaryDirectory() as temp_dir: unsquashfs_path = get_snap_tool_path("unsquashfs") try: @@ -61,9 +62,9 @@ def get_data_from_snap_file(snap_path): "-d", os.path.join(temp_dir, "squashfs-root"), snap_path, - "-e", # cygwin unsquashfs on windows uses unix paths. Path("meta", "snap.yaml").as_posix(), + Path("snap", "manifest.yaml").as_posix(), ] ) except subprocess.CalledProcessError: @@ -73,7 +74,12 @@ def get_data_from_snap_file(snap_path): os.path.join(temp_dir, "squashfs-root", "meta", "snap.yaml") ) as yaml_file: snap_yaml = yaml_utils.load(yaml_file) - return snap_yaml + manifest_path = Path(temp_dir, "squashfs-root", "snap", "manifest.yaml") + if manifest_path.exists(): + with open(manifest_path) as manifest_yaml_file: + manifest_yaml = yaml_utils.load(manifest_yaml_file) + + return snap_yaml, manifest_yaml @contextlib.contextmanager @@ -584,7 +590,7 @@ def sign_build(snap_filename, key_name=None, local=False): if not os.path.exists(snap_filename): raise FileNotFoundError("The file {!r} does not exist.".format(snap_filename)) - snap_yaml = get_data_from_snap_file(snap_filename) + snap_yaml, _ = get_data_from_snap_file(snap_filename) snap_name = snap_yaml["name"] grade = snap_yaml.get("grade", "stable") @@ -635,7 +641,7 @@ def upload_metadata(snap_filename, force): logger.debug("Uploading metadata to the Store (force=%s)", force) # get the metadata from the snap - snap_yaml = get_data_from_snap_file(snap_filename) + snap_yaml, _ = get_data_from_snap_file(snap_filename) metadata = { "summary": snap_yaml["summary"], "description": snap_yaml["description"], diff --git a/tests/legacy/data/test-snap-with-started-at.snap b/tests/legacy/data/test-snap-with-started-at.snap index 5a8879d93994785137fc9466fc220968b93f8e18..d4b641988e8586bb621cafebcd40cc4c17ea6b33 100644 GIT binary patch literal 4096 zcmc~OE-YqcU|@J9vm%9ofr)_;$Yx{^WHl>u>AYv_ysf-o!J=y8OD_U@ zy@UF8g^QVpY52Dv^wHV4Y*A*kpr3t1E?ca`CpE4~MLUg8cpY6cMe?EBq(u{L(^vVo zd@ldfD;M+h^Mk1Tdum~`u66~5&ePF!x@;-GMtZGvT+M;JBOCLbQnHNqO_o0eT?@wE4t zC+WGLt63+)-sc}vz&uecek1p}2m2a%J(wBVjtKNSf(%S(U{JF-doWLeXOPao4rs_(q0l?$Y;WWMCYO{PH_4xeuDMk84Pig>v#JBP^(6O(57N0XfukdhRTG{EAK!cI*fmLF`pCBJ z-@UJ&VvR!IhMk&mT;U3;;g7xnI6{BP#H?xIX8`t0c;>L+nr&5B{Z{)}?IUz6?7p=B fgoTqM;0QPZj({WJ2si?cfFs}tI0BBq|0M7QFp+3Q diff --git a/tests/legacy/unit/commands/test_sign_build.py b/tests/legacy/unit/commands/test_sign_build.py index c02078d901..71ddfc1e71 100644 --- a/tests/legacy/unit/commands/test_sign_build.py +++ b/tests/legacy/unit/commands/test_sign_build.py @@ -81,7 +81,7 @@ def test_sign_build_missing_account_info( self, mock_get_snap_data, ): - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None raised = self.assertRaises( storeapi.errors.StoreBuildAssertionPermissionError, @@ -104,7 +104,7 @@ def test_sign_build_no_usable_keys( self, mock_get_snap_data, ): - mock_get_snap_data.return_value = {"name": "snap-test", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "snap-test", "grade": "stable"}, None self.useFixture( fixtures.MockPatch("subprocess.check_output", return_value="[]".encode()) @@ -138,7 +138,7 @@ def test_sign_build_no_usable_named_key( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None self.useFixture( fixtures.MockPatch( "subprocess.check_output", return_value='[{"name": "default"}]'.encode() @@ -172,7 +172,7 @@ def test_sign_build_unregistered_key( "account_keys": [{"public-key-sha3-384": "another_hash"}], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None self.useFixture( fixtures.MockPatch( "subprocess.check_output", @@ -210,7 +210,7 @@ def test_sign_build_locally_successfully( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output, @@ -253,7 +253,7 @@ def test_sign_build_missing_grade( "account_keys": [{"public-key-sha3-384": "a_hash"}], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap"} + mock_get_snap_data.return_value = {"name": "test-snap"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output ) @@ -301,7 +301,7 @@ def test_sign_build_upload_successfully( ], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None fake_check_output = fixtures.MockPatch( "subprocess.check_output", side_effect=mock_check_output, @@ -350,7 +350,7 @@ def test_sign_build_upload_existing( "account_id": "abcd", "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"}, None snap_build_path = self.snap_test.snap_path + "-build" with open(snap_build_path, "wb") as fd: diff --git a/tests/unit/commands/test_upload.py b/tests/unit/commands/test_upload.py index a286822518..12068667e0 100644 --- a/tests/unit/commands/test_upload.py +++ b/tests/unit/commands/test_upload.py @@ -57,6 +57,20 @@ def snap_file(): ) +@pytest.fixture +def snap_file_with_started_at(): + return str( + ( + pathlib.Path(unit.__file__) + / ".." + / ".." + / "legacy" + / "data" + / "test-snap-with-started-at.snap" + ).resolve() + ) + + ################## # Upload Command # ################## @@ -96,6 +110,40 @@ def test_default( emitter.assert_message("Revision 10 created for 'basic'") +@pytest.mark.usefixtures("memory_keyring") +@pytest.mark.parametrize( + "command_class", (commands.StoreUploadCommand, commands.StoreLegacyPushCommand) +) +def test_built_at( + emitter, + fake_store_notify_upload, + fake_store_verify_upload, + snap_file_with_started_at, + command_class, +): + cmd = command_class(None) + + cmd.run( + argparse.Namespace( + snap_file=snap_file_with_started_at, + channels=None, + ) + ) + + assert fake_store_verify_upload.mock_calls == [call(ANY, snap_name="basic")] + assert fake_store_notify_upload.mock_calls == [ + call( + ANY, + snap_name="basic", + upload_id="2ecbfac1-3448-4e7d-85a4-7919b999f120", + built_at="2019-05-07T19:25:53.939041Z", + channels=None, + snap_file_size=4096, + ) + ] + emitter.assert_message("Revision 10 created for 'basic'") + + @pytest.mark.usefixtures("memory_keyring") def test_default_channels( emitter, fake_store_notify_upload, fake_store_verify_upload, snap_file From 8a6872cf7247b5074c959e818a302caed1d713a3 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Fri, 14 Jul 2023 16:26:03 -0300 Subject: [PATCH 13/14] Revert "requirements: update craft-archives (#4174)" This reverts commit 0e12894d236a715922ad439558057cd7ff4bda33. --- snapcraft_legacy/plugins/v1/catkin.py | 1 - snapcraft_legacy/plugins/v1/colcon.py | 1 - 2 files changed, 2 deletions(-) diff --git a/snapcraft_legacy/plugins/v1/catkin.py b/snapcraft_legacy/plugins/v1/catkin.py index 90d3ea9ebd..73fe9de2e1 100644 --- a/snapcraft_legacy/plugins/v1/catkin.py +++ b/snapcraft_legacy/plugins/v1/catkin.py @@ -282,7 +282,6 @@ def get_required_package_repositories(self) -> List[PackageRepository]: return [ PackageRepositoryApt( - type="apt", formats=["deb"], components=["main"], key_id="C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654", diff --git a/snapcraft_legacy/plugins/v1/colcon.py b/snapcraft_legacy/plugins/v1/colcon.py index 038a5268b2..73737be1ce 100644 --- a/snapcraft_legacy/plugins/v1/colcon.py +++ b/snapcraft_legacy/plugins/v1/colcon.py @@ -231,7 +231,6 @@ def get_required_package_repositories(cls) -> List[PackageRepository]: codename = os_release.OsRelease().version_codename() return [ PackageRepositoryApt( - type="apt", formats=["deb"], components=["main"], key_id="C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654", From 706872ec507a8fad57787dd8a60b2212dec23271 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Fri, 14 Jul 2023 16:36:13 -0300 Subject: [PATCH 14/14] Revert "repo: migrate to craft-archives (#4037)" This reverts commit ce23bc7d196f67d898dcd7735dc58eb60d558a6b. --- .../internal/meta/package_repository.py | 404 +++++++++++++++++ snapcraft_legacy/internal/meta/snap.py | 3 +- .../internal/project_loader/_config.py | 4 +- .../internal/repo/apt_key_manager.py | 226 ++++++++++ snapcraft_legacy/internal/repo/apt_ppa.py | 50 +++ .../internal/repo/apt_sources_manager.py | 248 +++++++++++ snapcraft_legacy/plugins/v1/_plugin.py | 3 +- snapcraft_legacy/plugins/v1/catkin.py | 7 +- snapcraft_legacy/plugins/v1/colcon.py | 7 +- .../unit/meta/test_package_repository.py | 411 ++++++++++++++++++ .../legacy/unit/repo/test_apt_key_manager.py | 339 +++++++++++++++ tests/legacy/unit/repo/test_apt_ppa.py | 62 +++ .../unit/repo/test_apt_sources_manager.py | 269 ++++++++++++ 13 files changed, 2019 insertions(+), 14 deletions(-) create mode 100644 snapcraft_legacy/internal/meta/package_repository.py create mode 100644 snapcraft_legacy/internal/repo/apt_key_manager.py create mode 100644 snapcraft_legacy/internal/repo/apt_ppa.py create mode 100644 snapcraft_legacy/internal/repo/apt_sources_manager.py create mode 100644 tests/legacy/unit/meta/test_package_repository.py create mode 100644 tests/legacy/unit/repo/test_apt_key_manager.py create mode 100644 tests/legacy/unit/repo/test_apt_ppa.py create mode 100644 tests/legacy/unit/repo/test_apt_sources_manager.py diff --git a/snapcraft_legacy/internal/meta/package_repository.py b/snapcraft_legacy/internal/meta/package_repository.py new file mode 100644 index 0000000000..b5cbe3126f --- /dev/null +++ b/snapcraft_legacy/internal/meta/package_repository.py @@ -0,0 +1,404 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2019 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 abc +import logging +import re +from copy import deepcopy +from typing import Any, Dict, List, Optional + +from . import errors + +logger = logging.getLogger(__name__) + + +class PackageRepository(abc.ABC): + @abc.abstractmethod + def marshal(self) -> Dict[str, Any]: + ... + + @classmethod + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepository": + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + if "ppa" in data: + return PackageRepositoryAptPpa.unmarshal(data) + + return PackageRepositoryApt.unmarshal(data) + + @classmethod + def unmarshal_package_repositories(cls, data: Any) -> List["PackageRepository"]: + repositories = list() + + if data is not None: + if not isinstance(data, list): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package-repositories list object {data!r}.", + details="Package repositories must be a list of objects.", + resolution="Verify 'package-repositories' configuration and ensure that the correct syntax is used.", + ) + + for repository in data: + package_repo = cls.unmarshal(repository) + repositories.append(package_repo) + + return repositories + + +class PackageRepositoryAptPpa(PackageRepository): + def __init__(self, *, ppa: str) -> None: + self.type = "apt" + self.ppa = ppa + + self.validate() + + def marshal(self) -> Dict[str, Any]: + data = dict(type="apt") + data["ppa"] = self.ppa + return data + + def validate(self) -> None: + if not self.ppa: + raise errors.PackageRepositoryValidationError( + url=self.ppa, + brief=f"Invalid PPA {self.ppa!r}.", + details="PPAs must be non-empty strings.", + resolution="Verify repository configuration and ensure that 'ppa' is correctly specified.", + ) + + @classmethod + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepositoryAptPpa": + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + data_copy = deepcopy(data) + + ppa = data_copy.pop("ppa", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution="Verify repository configuration and ensure that 'type' is correctly specified.", + ) + + if not isinstance(ppa, str): + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Invalid PPA {ppa!r}.", + details="PPA must be a valid string.", + resolution="Verify repository configuration and ensure that 'ppa' is correctly specified.", + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Found unsupported package repository properties {keys}.", + resolution="Verify repository configuration and ensure that it is correct.", + ) + + return cls(ppa=ppa) + + +class PackageRepositoryApt(PackageRepository): + def __init__( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + key_id: str, + key_server: Optional[str] = None, + name: Optional[str] = None, + path: Optional[str] = None, + suites: Optional[List[str]] = None, + url: str, + ) -> None: + self.type = "apt" + self.architectures = architectures + self.components = components + self.formats = formats + self.key_id = key_id + self.key_server = key_server + + if name is None: + # Default name is URL, stripping non-alphanumeric characters. + self.name: str = re.sub(r"\W+", "_", url) + else: + self.name = name + + self.path = path + self.suites = suites + self.url = url + + self.validate() + + def marshal(self) -> Dict[str, Any]: + data: Dict[str, Any] = {"type": "apt"} + + if self.architectures: + data["architectures"] = self.architectures + + if self.components: + data["components"] = self.components + + if self.formats: + data["formats"] = self.formats + + data["key-id"] = self.key_id + + if self.key_server: + data["key-server"] = self.key_server + + data["name"] = self.name + + if self.path: + data["path"] = self.path + + if self.suites: + data["suites"] = self.suites + + data["url"] = self.url + + return data + + def validate(self) -> None: # noqa: C901 + if self.formats is not None: + for repo_format in self.formats: + if repo_format not in ["deb", "deb-src"]: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid format {repo_format!r}.", + details="Valid formats include: deb and deb-src.", + resolution="Verify the repository configuration and ensure that 'formats' is correctly specified.", + ) + + if not self.key_id or not re.match(r"^[0-9A-F]{40}$", self.key_id): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid key identifier {self.key_id!r}.", + details="Key IDs must be 40 upper-case hex characters.", + resolution="Verify the repository configuration and ensure that 'key-id' is correctly specified.", + ) + + if not self.url: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid URL {self.url!r}.", + details="URLs must be non-empty strings.", + resolution="Verify the repository configuration and ensure that 'url' is correctly specified.", + ) + + if self.suites: + for suite in self.suites: + if suite.endswith("/"): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid suite {suite!r}.", + details="Suites must not end with a '/'.", + resolution="Verify the repository configuration and remove the trailing '/ from suites or use the 'path' property to define a path.", + ) + + if self.path is not None and self.path == "": + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Invalid path {self.path!r}.", + details="Paths must be non-empty strings.", + resolution="Verify the repository configuration and ensure that 'path' is a non-empty string such as '/'.", + ) + + if self.path and self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Components {self.components!r} cannot be combined with path {self.path!r}.", + details="Path and components are incomptiable options.", + resolution="Verify the repository configuration and remove 'path' or 'components'.", + ) + + if self.path and self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"Suites {self.suites!r} cannot be combined with path {self.path!r}.", + details="Path and suites are incomptiable options.", + resolution="Verify the repository configuration and remove 'path' or 'suites'.", + ) + + if self.suites and not self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="No components specified.", + details="Components are required when using suites.", + resolution="Verify the repository configuration and ensure that 'components' is correctly specified.", + ) + + if self.components and not self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="No suites specified.", + details="Suites are required when using components.", + resolution="Verify the repository configuration and ensure that 'suites' is correctly specified.", + ) + + @classmethod # noqa: C901 + def unmarshal(cls, data: Dict[str, Any]) -> "PackageRepositoryApt": # noqa: C901 + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief=f"Invalid package repository object {data!r}.", + details="Package repository must be a valid dictionary object.", + resolution="Verify repository configuration and ensure that the correct syntax is used.", + ) + + data_copy = deepcopy(data) + + architectures = data_copy.pop("architectures", None) + components = data_copy.pop("components", None) + formats = data_copy.pop("formats", None) + key_id = data_copy.pop("key-id", None) + key_server = data_copy.pop("key-server", None) + name = data_copy.pop("name", None) + path = data_copy.pop("path", None) + suites = data_copy.pop("suites", None) + url = data_copy.pop("url", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution="Verify repository configuration and ensure that 'type' is correctly specified.", + ) + + if architectures is not None and ( + not isinstance(architectures, list) + or not all(isinstance(x, str) for x in architectures) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid architectures {architectures!r}.", + details="Architectures must be a list of valid architecture strings.", + resolution="Verify repository configuration and ensure that 'architectures' is correctly specified.", + ) + + if components is not None and ( + not isinstance(components, list) + or not all(isinstance(x, str) for x in components) + or not components + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid components {components!r}.", + details="Components must be a list of strings.", + resolution="Verify repository configuration and ensure that 'components' is correctly specified.", + ) + + if formats is not None and ( + not isinstance(formats, list) + or not all(isinstance(x, str) for x in formats) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid formats {formats!r}.", + details="Formats must be a list of strings.", + resolution="Verify repository configuration and ensure that 'formats' is correctly specified.", + ) + + if not isinstance(key_id, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid key identifier {key_id!r}.", + details="Key identifiers must be a valid string.", + resolution="Verify repository configuration and ensure that 'key-id' is correctly specified.", + ) + + if key_server is not None and not isinstance(key_server, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid key server {key_server!r}.", + details="Key servers must be a valid string.", + resolution="Verify repository configuration and ensure that 'key-server' is correctly specified.", + ) + + if name is not None and not isinstance(name, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid name {name!r}.", + details="Names must be a valid string.", + resolution="Verify repository configuration and ensure that 'name' is correctly specified.", + ) + + if path is not None and not isinstance(path, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid path {path!r}.", + details="Paths must be a valid string.", + resolution="Verify repository configuration and ensure that 'path' is correctly specified.", + ) + + if suites is not None and ( + not isinstance(suites, list) + or not all(isinstance(x, str) for x in suites) + or not suites + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid suites {suites!r}.", + details="Suites must be a list of strings.", + resolution="Verify repository configuration and ensure that 'suites' is correctly specified.", + ) + + if not isinstance(url, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Invalid URL {url!r}.", + details="URLs must be a valid string.", + resolution="Verify repository configuration and ensure that 'url' is correctly specified.", + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"Found unsupported package repository properties {keys}.", + resolution="Verify repository configuration and ensure it is correct.", + ) + + return cls( + architectures=architectures, + components=components, + formats=formats, + key_id=key_id, + key_server=key_server, + name=name, + suites=suites, + url=url, + ) diff --git a/snapcraft_legacy/internal/meta/snap.py b/snapcraft_legacy/internal/meta/snap.py index cd6eba60b7..b355d10153 100644 --- a/snapcraft_legacy/internal/meta/snap.py +++ b/snapcraft_legacy/internal/meta/snap.py @@ -20,13 +20,12 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence, Set -from craft_archives.repo.package_repository import PackageRepository - from snapcraft_legacy import yaml_utils from snapcraft_legacy.internal import common from snapcraft_legacy.internal.meta import errors from snapcraft_legacy.internal.meta.application import Application from snapcraft_legacy.internal.meta.hooks import Hook +from snapcraft_legacy.internal.meta.package_repository import PackageRepository from snapcraft_legacy.internal.meta.plugs import ContentPlug, Plug from snapcraft_legacy.internal.meta.slots import ContentSlot, Slot from snapcraft_legacy.internal.meta.system_user import SystemUser diff --git a/snapcraft_legacy/internal/project_loader/_config.py b/snapcraft_legacy/internal/project_loader/_config.py index 2c859fefb3..a8a7cbdbb5 100644 --- a/snapcraft_legacy/internal/project_loader/_config.py +++ b/snapcraft_legacy/internal/project_loader/_config.py @@ -23,15 +23,15 @@ from typing import List, Set import jsonschema -from craft_archives.repo import apt_key_manager, apt_sources_manager -from craft_archives.repo.package_repository import PackageRepository from snapcraft_legacy import formatting_utils, plugins, project from snapcraft_legacy.internal import deprecations, repo, states, steps +from snapcraft_legacy.internal.meta.package_repository import PackageRepository from snapcraft_legacy.internal.meta.snap import Snap from snapcraft_legacy.internal.pluginhandler._part_environment import ( get_snapcraft_global_environment, ) +from snapcraft_legacy.internal.repo import apt_key_manager, apt_sources_manager from snapcraft_legacy.project._schema import Validator from . import errors, grammar_processing, replace_attr diff --git a/snapcraft_legacy/internal/repo/apt_key_manager.py b/snapcraft_legacy/internal/repo/apt_key_manager.py new file mode 100644 index 0000000000..0ea6f82a54 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_key_manager.py @@ -0,0 +1,226 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2020 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 logging +import pathlib +import subprocess +import tempfile +from typing import List, Optional + +import gnupg + +from snapcraft_legacy.internal.meta import package_repository + +from . import apt_ppa, errors + +logger = logging.getLogger(__name__) + + +class AptKeyManager: + def __init__( + self, + *, + gpg_keyring: pathlib.Path = pathlib.Path( + "/etc/apt/trusted.gpg.d/snapcraft.gpg" + ), + key_assets: pathlib.Path, + ) -> None: + self._gpg_keyring = gpg_keyring + self._key_assets = key_assets + + def find_asset_with_key_id(self, *, key_id: str) -> Optional[pathlib.Path]: + """Find snap key asset matching key_id. + + The key asset much be named with the last 8 characters of the key + identifier, in upper case. + + :param key_id: Key ID to search for. + + :returns: Path of key asset if match found, otherwise None. + """ + key_file = key_id[-8:].upper() + ".asc" + key_path = self._key_assets / key_file + + if key_path.exists(): + return key_path + + return None + + def get_key_fingerprints(self, *, key: str) -> List[str]: + """List fingerprints found in specified key. + + Do this by importing the key into a temporary keyring, + then querying the keyring for fingerprints. + + :param key: Key data (string) to parse. + + :returns: List of key fingerprints/IDs. + """ + with tempfile.NamedTemporaryFile(suffix="keyring") as temp_file: + return ( + gnupg.GPG(keyring=temp_file.name).import_keys(key_data=key).fingerprints + ) + + def is_key_installed(self, *, key_id: str) -> bool: + """Check if specified key_id is installed. + + Check if key is installed by attempting to export the key. + Unfortunately, apt-key does not exit with error and + we have to do our best to parse the output. + + :param key_id: Key ID to check for. + + :returns: True if key is installed. + """ + try: + proc = subprocess.run( + ["sudo", "apt-key", "export", key_id], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + ) + except subprocess.CalledProcessError as error: + # Export shouldn't exit with failure based on testing, + # but assume the key is not installed and log a warning. + logger.warning(f"Unexpected apt-key failure: {error.output}") + return False + + apt_key_output = proc.stdout.decode() + + if "BEGIN PGP PUBLIC KEY BLOCK" in apt_key_output: + return True + + if "nothing exported" in apt_key_output: + return False + + # The two strings above have worked in testing, but if neither is + # present for whatever reason, assume the key is not installed + # and log a warning. + logger.warning(f"Unexpected apt-key output: {apt_key_output}") + return False + + def install_key(self, *, key: str) -> None: + """Install given key. + + :param key: Key to install. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + cmd = [ + "sudo", + "apt-key", + "--keyring", + str(self._gpg_keyring), + "add", + "-", + ] + + try: + logger.debug(f"Executing: {cmd!r}") + env = dict() + env["LANG"] = "C.UTF-8" + subprocess.run( + cmd, + input=key.encode(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError(output=error.output.decode(), key=key) + + logger.debug(f"Installed apt repository key:\n{key}") + + def install_key_from_keyserver( + self, *, key_id: str, key_server: str = "keyserver.ubuntu.com" + ) -> None: + """Install key from specified key server. + + :param key_id: Key ID to install. + :param key_server: Key server to query. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + env = dict() + env["LANG"] = "C.UTF-8" + + cmd = [ + "sudo", + "apt-key", + "--keyring", + str(self._gpg_keyring), + "adv", + "--keyserver", + key_server, + "--recv-keys", + key_id, + ] + + try: + logger.debug(f"Executing: {cmd!r}") + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError( + output=error.output.decode(), key_id=key_id, key_server=key_server + ) + + def install_package_repository_key( + self, *, package_repo: package_repository.PackageRepository + ) -> bool: + """Install required key for specified package repository. + + For both PPA and other Apt package repositories: + 1) If key is already installed, return False. + 2) Install key from local asset, if available. + 3) Install key from key server, if available. An unspecified + keyserver will default to using keyserver.ubuntu.com. + + :param package_repo: Apt PackageRepository configuration. + + :returns: True if key configuration was changed. False if + key already installed. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + key_server: Optional[str] = None + if isinstance(package_repo, package_repository.PackageRepositoryAptPpa): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa=package_repo.ppa) + elif isinstance(package_repo, package_repository.PackageRepositoryApt): + key_id = package_repo.key_id + key_server = package_repo.key_server + else: + raise RuntimeError(f"unhandled package repo type: {package_repo!r}") + + # Already installed, nothing to do. + if self.is_key_installed(key_id=key_id): + return False + + key_path = self.find_asset_with_key_id(key_id=key_id) + if key_path is not None: + self.install_key(key=key_path.read_text()) + else: + if key_server is None: + key_server = "keyserver.ubuntu.com" + self.install_key_from_keyserver(key_id=key_id, key_server=key_server) + + return True diff --git a/snapcraft_legacy/internal/repo/apt_ppa.py b/snapcraft_legacy/internal/repo/apt_ppa.py new file mode 100644 index 0000000000..f4269d1d04 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_ppa.py @@ -0,0 +1,50 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 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 logging +from typing import Tuple + +import lazr.restfulclient.errors +from launchpadlib.launchpad import Launchpad + +from . import errors + +logger = logging.getLogger(__name__) + + +def split_ppa_parts(*, ppa: str) -> Tuple[str, str]: + ppa_split = ppa.split("/") + if len(ppa_split) != 2: + raise errors.AptPPAInstallError(ppa=ppa, reason="invalid PPA format") + return ppa_split[0], ppa_split[1] + + +def get_launchpad_ppa_key_id(*, ppa: str) -> str: + """Query Launchpad for PPA's key ID.""" + owner, name = split_ppa_parts(ppa=ppa) + launchpad = Launchpad.login_anonymously("snapcraft", "production") + launchpad_url = f"~{owner}/+archive/{name}" + + logger.debug(f"Loading launchpad url: {launchpad_url}") + try: + key_id = launchpad.load(launchpad_url).signing_key_fingerprint + except lazr.restfulclient.errors.NotFound as error: + raise errors.AptPPAInstallError( + ppa=ppa, reason="not found on launchpad" + ) from error + + logger.debug(f"Retrieved launchpad PPA key ID: {key_id}") + return key_id diff --git a/snapcraft_legacy/internal/repo/apt_sources_manager.py b/snapcraft_legacy/internal/repo/apt_sources_manager.py new file mode 100644 index 0000000000..8d979c37c6 --- /dev/null +++ b/snapcraft_legacy/internal/repo/apt_sources_manager.py @@ -0,0 +1,248 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2021 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 . +# +"""Manage the host's apt source repository configuration.""" + +import io +import logging +import os +import pathlib +import re +import subprocess +import tempfile +from typing import List, Optional + +from snapcraft_legacy.internal import os_release +from snapcraft_legacy.internal.meta import package_repository +from snapcraft_legacy.project._project_options import ProjectOptions + +from . import apt_ppa + +logger = logging.getLogger(__name__) + + +def _construct_deb822_source( + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + suites: List[str], + url: str, +) -> str: + """Construct deb-822 formatted sources.list config string.""" + with io.StringIO() as deb822: + if formats: + type_text = " ".join(formats) + else: + type_text = "deb" + + print(f"Types: {type_text}", file=deb822) + + print(f"URIs: {url}", file=deb822) + + suites_text = " ".join(suites) + print(f"Suites: {suites_text}", file=deb822) + + if components: + components_text = " ".join(components) + print(f"Components: {components_text}", file=deb822) + + if architectures: + arch_text = " ".join(architectures) + else: + arch_text = _get_host_arch() + + print(f"Architectures: {arch_text}", file=deb822) + + return deb822.getvalue() + + +def _get_host_arch() -> str: + return ProjectOptions().deb_arch + + +def _sudo_write_file(*, dst_path: pathlib.Path, content: bytes) -> None: + """Workaround for writing privileged files in destructive mode.""" + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(content) + temp_file.flush() + f_name = temp_file.name + + try: + command = [ + "sudo", + "install", + "--owner=root", + "--group=root", + "--mode=0644", + f_name, + str(dst_path), + ] + subprocess.run(command, check=True) + except subprocess.CalledProcessError as error: + raise RuntimeError( + f"Failed to install repository config with: {command!r}" + ) from error + finally: + os.unlink(f_name) + + +class AptSourcesManager: + """Manage apt source configuration in /etc/apt/sources.list.d. + + :param sources_list_d: Path to sources.list.d directory. + """ + + # pylint: disable=too-few-public-methods + def __init__( + self, + *, + sources_list_d: pathlib.Path = pathlib.Path("/etc/apt/sources.list.d"), + ) -> None: + self._sources_list_d = sources_list_d + + def _install_sources( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + name: str, + suites: List[str], + url: str, + ) -> bool: + """Install sources list configuration. + + Write config to: + /etc/apt/sources.list.d/snapcraft-.sources + + :returns: True if configuration was changed. + """ + config = _construct_deb822_source( + architectures=architectures, + components=components, + formats=formats, + suites=suites, + url=url, + ) + + if name not in ["default", "default-security"]: + name = "snapcraft-" + name + + config_path = self._sources_list_d / f"{name}.sources" + if config_path.exists() and config_path.read_text() == config: + # Already installed and matches, nothing to do. + logger.debug("Ignoring unchanged sources: %s", str(config_path)) + return False + + _sudo_write_file(dst_path=config_path, content=config.encode()) + logger.debug("Installed sources: %s", str(config_path)) + return True + + def _install_sources_apt( + self, *, package_repo: package_repository.PackageRepositoryApt + ) -> bool: + """Install repository configuration. + + 1) First check to see if package repo is implied path, + or "bare repository" config. This is indicated when no + path, components, or suites are indicated. + 2) If path is specified, convert path to a suite entry, + ending with "/". + + Relatedly, this assumes all of the error-checking has been + done already on the package_repository object in a proper + fashion, but do some sanity checks here anyways. + + :returns: True if source configuration was changed. + """ + if ( + not package_repo.path + and not package_repo.components + and not package_repo.suites + ): + suites = ["/"] + elif package_repo.path: + # Suites denoting exact path must end with '/'. + path = package_repo.path + if not path.endswith("/"): + path += "/" + suites = [path] + elif package_repo.suites: + suites = package_repo.suites + if not package_repo.components: + raise RuntimeError("no components with suite") + else: + raise RuntimeError("no suites or path") + + if package_repo.name: + name = package_repo.name + else: + name = re.sub(r"\W+", "_", package_repo.url) + + return self._install_sources( + architectures=package_repo.architectures, + components=package_repo.components, + formats=package_repo.formats, + name=name, + suites=suites, + url=package_repo.url, + ) + + def _install_sources_ppa( + self, *, package_repo: package_repository.PackageRepositoryAptPpa + ) -> bool: + """Install PPA formatted repository. + + Create a sources list config by: + - Looking up the codename of the host OS and using it as the "suites" + entry. + - Formulate deb URL to point to PPA. + - Enable only "deb" formats. + + :returns: True if source configuration was changed. + """ + owner, name = apt_ppa.split_ppa_parts(ppa=package_repo.ppa) + codename = os_release.OsRelease().version_codename() + + return self._install_sources( + components=["main"], + formats=["deb"], + name=f"ppa-{owner}_{name}", + suites=[codename], + url=f"http://ppa.launchpad.net/{owner}/{name}/ubuntu", + ) + + def install_package_repository_sources( + self, + *, + package_repo: package_repository.PackageRepository, + ) -> bool: + """Install configured package repositories. + + :param package_repo: Repository to install the source configuration for. + + :returns: True if source configuration was changed. + """ + logger.debug("Processing repo: %r", package_repo) + if isinstance(package_repo, package_repository.PackageRepositoryAptPpa): + return self._install_sources_ppa(package_repo=package_repo) + + if isinstance(package_repo, package_repository.PackageRepositoryApt): + return self._install_sources_apt(package_repo=package_repo) + + raise RuntimeError(f"unhandled package repository: {package_repository!r}") diff --git a/snapcraft_legacy/plugins/v1/_plugin.py b/snapcraft_legacy/plugins/v1/_plugin.py index fdc992311c..7c6e5801fa 100644 --- a/snapcraft_legacy/plugins/v1/_plugin.py +++ b/snapcraft_legacy/plugins/v1/_plugin.py @@ -21,9 +21,8 @@ from subprocess import CalledProcessError from typing import List -from craft_archives.repo.package_repository import PackageRepository - from snapcraft_legacy.internal import common, errors +from snapcraft_legacy.internal.meta.package_repository import PackageRepository from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) diff --git a/snapcraft_legacy/plugins/v1/catkin.py b/snapcraft_legacy/plugins/v1/catkin.py index 73fe9de2e1..0bf45cf79d 100644 --- a/snapcraft_legacy/plugins/v1/catkin.py +++ b/snapcraft_legacy/plugins/v1/catkin.py @@ -81,13 +81,12 @@ import textwrap from typing import TYPE_CHECKING, List, Set -from craft_archives.repo.package_repository import ( +from snapcraft_legacy import file_utils, formatting_utils +from snapcraft_legacy.internal import common, errors, mangling, os_release, repo +from snapcraft_legacy.internal.meta.package_repository import ( PackageRepository, PackageRepositoryApt, ) - -from snapcraft_legacy import file_utils, formatting_utils -from snapcraft_legacy.internal import common, errors, mangling, os_release, repo from snapcraft_legacy.plugins.v1 import PluginV1, _python, _ros if TYPE_CHECKING: diff --git a/snapcraft_legacy/plugins/v1/colcon.py b/snapcraft_legacy/plugins/v1/colcon.py index 73737be1ce..522e2db918 100644 --- a/snapcraft_legacy/plugins/v1/colcon.py +++ b/snapcraft_legacy/plugins/v1/colcon.py @@ -66,13 +66,12 @@ import textwrap from typing import List -from craft_archives.repo.package_repository import ( +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import errors, mangling, os_release, repo +from snapcraft_legacy.internal.meta.package_repository import ( PackageRepository, PackageRepositoryApt, ) - -from snapcraft_legacy import file_utils -from snapcraft_legacy.internal import errors, mangling, os_release, repo from snapcraft_legacy.plugins.v1 import PluginV1, _python, _ros logger = logging.getLogger(__name__) diff --git a/tests/legacy/unit/meta/test_package_repository.py b/tests/legacy/unit/meta/test_package_repository.py new file mode 100644 index 0000000000..c81c6c5688 --- /dev/null +++ b/tests/legacy/unit/meta/test_package_repository.py @@ -0,0 +1,411 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2019 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 pytest + +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepository, + PackageRepositoryApt, + PackageRepositoryAptPpa, +) + + +def test_apt_name(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="keyserver.ubuntu.com", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.name == "http_archive_ubuntu_com_ubuntu" + + +@pytest.mark.parametrize( + "arch", ["amd64", "armhf", "arm64", "i386", "ppc64el", "riscv", "s390x"] +) +def test_apt_valid_architectures(arch): + package_repo = PackageRepositoryApt( + key_id="A" * 40, url="http://test", architectures=[arch] + ) + + assert package_repo.architectures == [arch] + + +def test_apt_invalid_url(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + url="", + ) + + assert exc_info.value.brief == "Invalid URL ''." + assert exc_info.value.details == "URLs must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'url' is correctly specified." + ) + + +def test_apt_invalid_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="", + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "Invalid path ''." + assert exc_info.value.details == "Paths must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'path' is a non-empty string such as '/'." + ) + + +def test_apt_invalid_path_with_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert ( + exc_info.value.brief + == "Suites ['xenial', 'xenial-updates'] cannot be combined with path '/'." + ) + assert exc_info.value.details == "Path and suites are incomptiable options." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove 'path' or 'suites'." + ) + + +def test_apt_invalid_path_with_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert ( + exc_info.value.brief == "Components ['main'] cannot be combined with path '/'." + ) + assert exc_info.value.details == "Path and components are incomptiable options." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove 'path' or 'components'." + ) + + +def test_apt_invalid_missing_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "No components specified." + assert exc_info.value.details == "Components are required when using suites." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'components' is correctly specified." + ) + + +def test_apt_invalid_missing_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "No suites specified." + assert exc_info.value.details == "Suites are required when using components." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and ensure that 'suites' is correctly specified." + ) + + +def test_apt_invalid_suites_as_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt( + key_id="A" * 40, + suites=["my-suite/"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert exc_info.value.brief == "Invalid suite 'my-suite/'." + assert exc_info.value.details == "Suites must not end with a '/'." + assert ( + exc_info.value.resolution + == "Verify the repository configuration and remove the trailing '/ from suites or use the 'path' property to define a path." + ) + + +def test_apt_marshal(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="xkeyserver.ubuntu.com", + name="test-name", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.marshal() == { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "xkeyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + +def test_apt_unmarshal_invalid_extra_keys(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + "foo": "bar", + "foo2": "bar", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert ( + exc_info.value.brief + == "Found unsupported package repository properties 'foo', 'foo2'." + ) + assert exc_info.value.details is None + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure it is correct." + ) + + +def test_apt_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert exc_info.value.brief == "Invalid package repository object 'not-a-dict'." + assert ( + exc_info.value.details + == "Package repository must be a valid dictionary object." + ) + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_apt_unmarshal_invalid_type(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "aptx", + "url": "http://archive.ubuntu.com/ubuntu", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryApt.unmarshal(test_dict) + + assert exc_info.value.brief == "Unsupported type 'aptx'." + assert exc_info.value.details == "The only currently supported type is 'apt'." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_marshal(): + repo = PackageRepositoryAptPpa(ppa="test/ppa") + + assert repo.marshal() == {"type": "apt", "ppa": "test/ppa"} + + +def test_ppa_invalid_ppa(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa(ppa="") + + assert exc_info.value.brief == "Invalid PPA ''." + assert exc_info.value.details == "PPAs must be non-empty strings." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'ppa' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert exc_info.value.brief == "Invalid package repository object 'not-a-dict'." + assert ( + exc_info.value.details + == "Package repository must be a valid dictionary object." + ) + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_type(): + test_dict = {"type": "aptx", "ppa": "test/ppa"} + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert exc_info.value.brief == "Unsupported type 'aptx'." + assert exc_info.value.details == "The only currently supported type is 'apt'." + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_extra_keys(): + test_dict = {"type": "apt", "ppa": "test/ppa", "test": "foo"} + + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepositoryAptPpa.unmarshal(test_dict) + + assert ( + exc_info.value.brief + == "Found unsupported package repository properties 'test'." + ) + assert exc_info.value.details is None + assert ( + exc_info.value.resolution + == "Verify repository configuration and ensure that it is correct." + ) + + +def test_unmarshal_package_repositories_list_none(): + assert PackageRepository.unmarshal_package_repositories(None) == list() + + +def test_unmarshal_package_repositories_list_empty(): + assert PackageRepository.unmarshal_package_repositories(list()) == list() + + +def test_unmarshal_package_repositories_list_ppa(): + test_dict = {"type": "apt", "ppa": "test/foo"} + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_apt(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_all(): + test_ppa = {"type": "apt", "ppa": "test/foo"} + + test_deb = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_ppa, test_deb] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_invalid_data(): + with pytest.raises(errors.PackageRepositoryValidationError) as exc_info: + PackageRepository.unmarshal_package_repositories("not-a-list") + + assert ( + exc_info.value.brief == "Invalid package-repositories list object 'not-a-list'." + ) + assert exc_info.value.details == "Package repositories must be a list of objects." + assert ( + exc_info.value.resolution + == "Verify 'package-repositories' configuration and ensure that the correct syntax is used." + ) diff --git a/tests/legacy/unit/repo/test_apt_key_manager.py b/tests/legacy/unit/repo/test_apt_key_manager.py new file mode 100644 index 0000000000..e5e14bbaab --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_key_manager.py @@ -0,0 +1,339 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 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 subprocess +from unittest import mock +from unittest.mock import call + +import gnupg +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, errors +from snapcraft_legacy.internal.repo.apt_key_manager import AptKeyManager + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_gnupg(tmp_path, autouse=True): + with mock.patch("gnupg.GPG", spec=gnupg.GPG) as m: + m.return_value.import_keys.return_value.fingerprints = [ + "FAKE-KEY-ID-FROM-GNUPG" + ] + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run", spec=subprocess.run) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture +def key_assets(tmp_path): + key_assets = tmp_path / "key-assets" + key_assets.mkdir(parents=True) + yield key_assets + + +@pytest.fixture +def gpg_keyring(tmp_path): + yield tmp_path / "keyring.gpg" + + +@pytest.fixture +def apt_gpg(key_assets, gpg_keyring): + yield AptKeyManager( + gpg_keyring=gpg_keyring, + key_assets=key_assets, + ) + + +def test_find_asset( + apt_gpg, + key_assets, +): + key_id = "8" * 40 + expected_key_path = key_assets / ("8" * 8 + ".asc") + expected_key_path.write_text("key") + + key_path = apt_gpg.find_asset_with_key_id(key_id=key_id) + + assert key_path == expected_key_path + + +def test_find_asset_none( + apt_gpg, +): + key_path = apt_gpg.find_asset_with_key_id(key_id="foo") + + assert key_path is None + + +def test_get_key_fingerprints( + apt_gpg, + mock_gnupg, +): + with mock.patch("tempfile.NamedTemporaryFile") as m: + m.return_value.__enter__.return_value.name = "/tmp/foo" + ids = apt_gpg.get_key_fingerprints(key="8" * 40) + + assert ids == ["FAKE-KEY-ID-FROM-GNUPG"] + assert mock_gnupg.mock_calls == [ + call(keyring="/tmp/foo"), + call().import_keys(key_data="8888888888888888888888888888888888888888"), + ] + + +@pytest.mark.parametrize( + "stdout,expected", + [ + (b"nothing exported", False), + (b"BEGIN PGP PUBLIC KEY BLOCK", True), + (b"invalid", False), + ], +) +def test_is_key_installed( + stdout, + expected, + apt_gpg, + mock_run, +): + mock_run.return_value.stdout = stdout + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is expected + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "export", "foo"], + check=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_is_key_installed_with_apt_key_failure( + apt_gpg, + mock_run, +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is False + + +def test_install_key( + apt_gpg, + gpg_keyring, + mock_run, +): + key = "some-fake-key" + apt_gpg.install_key(key=key) + + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "--keyring", str(gpg_keyring), "add", "-"], + check=True, + env={"LANG": "C.UTF-8"}, + input=b"some-fake-key", + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_with_apt_key_failure(apt_gpg, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["foo"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key(key="FAKEKEY") + + assert exc_info.value._output == "some error" + assert exc_info.value._key == "FAKEKEY" + + +def test_install_key_from_keyserver(apt_gpg, gpg_keyring, mock_run): + apt_gpg.install_key_from_keyserver(key_id="FAKE_KEYID", key_server="key.server") + + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "apt-key", + "--keyring", + str(gpg_keyring), + "adv", + "--keyserver", + "key.server", + "--recv-keys", + "FAKE_KEYID", + ], + check=True, + env={"LANG": "C.UTF-8"}, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_from_keyserver_with_apt_key_failure( + apt_gpg, gpg_keyring, mock_run +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key_from_keyserver( + key_id="fake-key-id", key_server="fake-server" + ) + + assert exc_info.value._output == "some error" + assert exc_info.value._key_id == "fake-key-id" + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed" +) +@pytest.mark.parametrize( + "is_installed", + [True, False], +) +def test_install_package_repository_key_already_installed( + mock_is_key_installed, + is_installed, + apt_gpg, +): + mock_is_key_installed.return_value = is_installed + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id="8" * 40, + key_server="xkeyserver.com", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is not is_installed + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch("snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key") +def test_install_package_repository_key_from_asset( + mock_install_key, + mock_is_key_installed, + apt_gpg, + key_assets, +): + key_id = "123456789012345678901234567890123456AABB" + expected_key_path = key_assets / "3456AABB.asc" + expected_key_path.write_text("key-data") + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key.mock_calls == [call(key="key-data")] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_apt_from_keyserver( + mock_install_key_from_keyserver, + mock_is_key_installed, + apt_gpg, +): + key_id = "8" * 40 + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + key_server="key.server", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id=key_id, key_server="key.server") + ] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_ppa_from_keyserver( + mock_install_key_from_keyserver, + mock_is_key_installed, + apt_gpg, +): + package_repo = PackageRepositoryAptPpa( + ppa="test/ppa", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id="FAKE-PPA-SIGNING-KEY", key_server="keyserver.ubuntu.com") + ] diff --git a/tests/legacy/unit/repo/test_apt_ppa.py b/tests/legacy/unit/repo/test_apt_ppa.py new file mode 100644 index 0000000000..095f70113b --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_ppa.py @@ -0,0 +1,62 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 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 . + + +from unittest import mock +from unittest.mock import call + +import launchpadlib +import pytest + +from snapcraft_legacy.internal.repo import apt_ppa, errors + + +@pytest.fixture +def mock_launchpad(autouse=True): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.Launchpad", + spec=launchpadlib.launchpad.Launchpad, + ) as m: + m.login_anonymously.return_value.load.return_value.signing_key_fingerprint = ( + "FAKE-PPA-SIGNING-KEY" + ) + yield m + + +def test_split_ppa_parts(): + owner, name = apt_ppa.split_ppa_parts(ppa="test-owner/test-name") + + assert owner == "test-owner" + assert name == "test-name" + + +def test_split_ppa_parts_invalid(): + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_ppa.split_ppa_parts(ppa="ppa-missing-slash") + + assert exc_info.value._ppa == "ppa-missing-slash" + + +def test_get_launchpad_ppa_key_id( + mock_launchpad, +): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa="ppa-owner/ppa-name") + + assert key_id == "FAKE-PPA-SIGNING-KEY" + assert mock_launchpad.mock_calls == [ + call.login_anonymously("snapcraft", "production"), + call.login_anonymously().load("~ppa-owner/+archive/ppa-name"), + ] diff --git a/tests/legacy/unit/repo/test_apt_sources_manager.py b/tests/legacy/unit/repo/test_apt_sources_manager.py new file mode 100644 index 0000000000..2f8f59bd10 --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_sources_manager.py @@ -0,0 +1,269 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2021 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 pathlib +import subprocess +from textwrap import dedent +from unittest import mock +from unittest.mock import call + +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, apt_sources_manager, errors + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_host_arch(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager.ProjectOptions" + ) as m: + m.return_value.deb_arch = "FAKE-HOST-ARCH" + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run") as m: + yield m + + +@pytest.fixture() +def mock_sudo_write(): + def write_file(*, dst_path: pathlib.Path, content: bytes) -> None: + dst_path.write_bytes(content) + + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager._sudo_write_file" + ) as m: + m.side_effect = write_file + yield m + + +@pytest.fixture(autouse=True) +def mock_version_codename(): + with mock.patch( + "snapcraft_legacy.internal.os_release.OsRelease.version_codename", + return_value="FAKE-CODENAME", + ) as m: + yield m + + +@pytest.fixture +def apt_sources_mgr(tmp_path): + sources_list_d = tmp_path / "sources.list.d" + sources_list_d.mkdir(parents=True) + + yield apt_sources_manager.AptSourcesManager( + sources_list_d=sources_list_d, + ) + + +@mock.patch("tempfile.NamedTemporaryFile") +@mock.patch("os.unlink") +def test_sudo_write_file(mock_unlink, mock_tempfile, mock_run, tmp_path): + mock_tempfile.return_value.__enter__.return_value.name = "/tmp/foobar" + + apt_sources_manager._sudo_write_file(dst_path="/foo/bar", content=b"some-content") + + assert mock_tempfile.mock_calls == [ + call(delete=False), + call().__enter__(), + call().__enter__().write(b"some-content"), + call().__enter__().flush(), + call().__exit__(None, None, None), + ] + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "install", + "--owner=root", + "--group=root", + "--mode=0644", + "/tmp/foobar", + "/foo/bar", + ], + check=True, + ) + ] + assert mock_unlink.mock_calls == [call("/tmp/foobar")] + + +def test_sudo_write_file_fails(mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["sudo"], returncode=1, output=b"some error" + ) + + with pytest.raises(RuntimeError) as error: + apt_sources_manager._sudo_write_file( + dst_path="/foo/bar", content=b"some-content" + ) + + assert ( + str(error.value).startswith( + "Failed to install repository config with: ['sudo', 'install'" + ) + is True + ) + + +@pytest.mark.parametrize( + "package_repo,name,content", + [ + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + formats=["deb", "deb-src"], + key_id="A" * 40, + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-http_test_url_ubuntu.sources", + dedent( + """\ + Types: deb deb-src + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + key_id="A" * 40, + name="NO-FORMAT", + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-NO-FORMAT.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, + name="WITH-PATH", + path="some-path", + url="http://test.url/ubuntu", + ), + "snapcraft-WITH-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: some-path/ + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, + name="IMPLIED-PATH", + url="http://test.url/ubuntu", + ), + "snapcraft-IMPLIED-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: / + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryAptPpa(ppa="test/ppa"), + "snapcraft-ppa-test_ppa.sources", + dedent( + """\ + Types: deb + URIs: http://ppa.launchpad.net/test/ppa/ubuntu + Suites: FAKE-CODENAME + Components: main + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ], +) +def test_install(package_repo, name, content, apt_sources_mgr, mock_sudo_write): + sources_path = apt_sources_mgr._sources_list_d / name + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is True + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [ + call( + content=content, + dst_path=sources_path, + ) + ] + + # Verify a second-run does not incur any changes. + mock_sudo_write.reset_mock() + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is False + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [] + + +def test_install_ppa_invalid(apt_sources_mgr): + repo = PackageRepositoryAptPpa(ppa="ppa-missing-slash") + + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_sources_mgr.install_package_repository_sources(package_repo=repo) + + assert exc_info.value._ppa == "ppa-missing-slash"