diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3d4f4992d926e081c48d4730f79a050ec57d3770 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +*.egg-info +version.py \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..2904877489ed83d1ab3e641d0205bbf789e16d1d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,13 @@ +image: python:latest + +before_script: + - python3 -m venv env + - source env/bin/activate + +test: + script: + - python -m pip install .[dev,test] + - python -m black src tests + - python -m pyflakes src tests + - python -m mypy src tests + - python -m pytest diff --git a/COPYING b/COPYING new file mode 100644 index 0000000000000000000000000000000000000000..90366dd40b5e728318cfd10c0eaac2499d428cda --- /dev/null +++ b/COPYING @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Codethink Limited +Copyright (c) 2024 GNOME Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..4de5cc8a244e06132bccfd93a124a672ad339ed6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,154 @@ +# sysext-utils + +sysext-utils is a collection of tools, built on top of [systemd-sysext](https://www.freedesktop.org/software/systemd/man/latest/systemd-sysext.html), that aims to provide a better experience developing and testing system components for [GNOME OS](https://os.gnome.org). + +The basic idea is to facilitate building and managing system extensions that can be overlaid on top of the OS. This way, one can add modified or new components to its system to test. Check out the [original proposal](https://discourse.gnome.org/t/towards-a-better-way-to-hack-and-test-your-system-components/21075) for the rationale behind this project. + +To achieve this, sysext-utils provides the following tools: + +* sysext-build: creates an extension out of a [Meson](https://mesonbuild.com/) or [BuildStream](https://buildstream.build/) project, or an OS tree directory. +* sysext-add: imports, activates and integrates an extension. +* sysext-remove: deactivates and deintegrates an extension. +* sysext-install: combines both *sysext-build* and *sysext-add* functionalities into one, for convenience. + +Although this project is primarily designed for GNOME OS, the tool itself and its principles could be expanded to any other OS with systemd version 256 or newer. + +## Requirements + +* An installation of [GNOME OS](https://gitlab.gnome.org/GNOME/gnome-build-meta/-/wikis/gnome_os/Start-GNOME-OS#getting-the-images) with the sysupdate variant. +* The development tree [sysext](https://gitlab.gnome.org/GNOME/gnome-build-meta/-/wikis/gnome_os/Install-Software#install-developer-tools-and-system-extensions-with-systemd-sysext) of GNOME OS, to pull all required dependencies. + +Alternatively, the core GNOME OS OCI image can be used to build extensions with *sysext-build*. A few extra dependencies might be needed, depending on the target build system and extension image format. See this [example](./docker). + +## Installation + +To install sysext-utils simply follow these steps: + +```bash +$ git clone https://gitlab.gnome.org/tchx84/sysext-utils.git +$ cd sysext-utils +$ python3 -m venv env +$ source env/bin/activate +$ python -m pip install . +``` + +## Usage + +### Building extensions + +*sysext-build* can facilitate the creation of extensions out of projects with different build systems. + +#### Meson + +```bash +$ git clone https://gitlab.gnome.org/GNOME/gtk.git +$ cd gtk +$ meson setup ./build --prefix /usr +# do development +$ sysext-build example ./build +# obtain the example.sysext.raw extension image +``` + +#### BuildStream + +```bash +$ git clone https://gitlab.gnome.org/GNOME/gnome-build-meta +$ cd gnome-build-meta +$ bst workspace open --directory ./workspace sdk/gtk.bst +# do development +$ sysext-build example ./workspace +# obtain the example.sysext.raw extension image +``` + +#### An OS tree directory + +```bash +$ git clone https://gitlab.gnome.org/tchx84/sysext-utils.git +$ cd sysext-utils +# do development and use another build system to produce an OS tree directory +$ sysext-build example ./tests/data/example +# obtain the example.sysext.raw extension image +``` + +#### Notes + +* The first argument to *sysext-build* is the name to be given to the extension. The name can be any valid filename. +* The second argument must be a directory. *sysext-build* will try to detect the proper build system to use. If needed, one can force a build system by using the `--system` optional argument. +* By default, *sysext-build* will use *mksquashfs* to make the extension images. Images can also be signed by specifying the `--format=ddi` optional argument and providing a private key and certificate. +* By default, *sysext-build* will set metadata to ensure that the extension can only be used in identical systems. This behavior can be overridden by using the `--ignore-release` optional argument. This is useful when building extensions in a separate environment like in a CI pipeline. +* See `sysext-build --help` for more details. + +### Managing extensions + +Whether the extension is available locally or remotely, it can be added to the system as follows: + +#### Adding an extension + +```bash +$ sysext-add example.sysext.raw +$ sysext-add https://os.gnome.org/sysext/example.sysext.raw +# do testing +``` + +#### Removing an extension + +```bash +$ sysext-remove example +``` + +#### Notes + +* Adding an extension will run integration steps like recompiling the glib schemas and updating the icons cache. These integration artifacts are added in a separate extension which is global for all extensions. +* Removing an extension will also run these integration steps. The global integration extension will be removed along with the last extension. +* By default, extensions are added under `/run/extensions`. This means that the extension will be removed after a system reboot, which is a safer default behavior. This can be overridden by using the `--persistent` optional argument. +* See `sysext-add --help` and `sysext-remove --help` for more details. + +### Facilitating local development + +Constantly building and adding extensions can be tedious when testing things locally, therefore a *sysext-install* convenience tool is included to do both in a single step. + +#### A single step + +```bash +$ git clone https://gitlab.gnome.org/GNOME/gtk.git +$ cd gtk +$ meson setup ./build --prefix /usr +# do development +$ sysext-install example ./build +# do testing +``` + +#### Notes + +* *sysext-install* combines both *sysext-build* and *sysext-add* functionalities and arguments and therefore follows the exact same defaults. +* See `sysext-install --help` for more details. + +## What else is missing? + +See the list of [issues](https://gitlab.gnome.org/tchx84/sysext-utils/-/issues) for opportunities to improve this tool. + +## License + +MIT License + +Copyright (c) 2024 Codethink Limited + +Copyright (c) 2024 GNOME Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7da764a76ff86f0b15007e4f283f9ca22f1739c0 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,7 @@ +FROM quay.io/gnome_infrastructure/gnome-build-meta:core-nightly + +RUN git clone --branch v6.1.1 --single-branch https://github.com/plougher/squashfs-tools.git && \ + (cd squashfs-tools/squashfs-tools && sudo make install INSTALL_PREFIX=/usr) + +RUN git clone --branch main --single-branch https://gitlab.gnome.org/tchx84/sysext-utils.git && \ + (cd sysext-utils && sudo python -m pip install .) diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..691a1f5795ae0ae5990cb043eb48a824d1bc2639 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,16 @@ +# sysext-utils Toolbx + +This directory contains a sample Dockerfile based on the [core](https://gitlab.gnome.org/GNOME/gnome-build-meta) GNOME OS OCI image, plus *mksquashfs* and *sysext-utils* itself. This can create a suitable Toolbx environment to create images that are not signed, out of any GNOME project with the meson build system. + +## Build it + +```bash +$ podman build . -t sysext-utils:latest +$ toolbox create -c sysext-utils -i sysext-utils +``` + +## Use it + +```bash +$ toolbox enter sysext-utils +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..6a1e8ef5022fbe4bd7269c13359fd7d512742e8b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools >= 61.0", "setuptools-scm>=8.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sysext-utils" +dynamic = ["version"] +requires-python = ">=3.8" + +[project.scripts] +sysext-add = "sysext_utils:main.add" +sysext-remove = "sysext_utils:main.remove" +sysext-build = "sysext_utils:main.build" +sysext-install = "sysext_utils:main.install" + +[tool.setuptools_scm] +version_file = "src/sysext_utils/version.py" + +[project.optional-dependencies] +dev = [ + "black >=24.4.2", + "pyflakes >=3.2.0", + "mypy >=1.10.0", +] +test = [ + "pytest-cov >=5.0.0", +] + +[tool.pytest.ini_options] +addopts = "--cov --cov-report html --cov-report term-missing --cov-fail-under 90" + +[tool.coverage.run] +source = ["sysext_utils"] +omit = [ + "main.py", + "version.py", +] diff --git a/src/sysext_utils/__init__.py b/src/sysext_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..adbf5803c27089171ffc1a9d9940a162b7565b2b --- /dev/null +++ b/src/sysext_utils/__init__.py @@ -0,0 +1,24 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT diff --git a/src/sysext_utils/commands.py b/src/sysext_utils/commands.py new file mode 100644 index 0000000000000000000000000000000000000000..c09bc7d79200f75cab496288c7c5bde1e118ceac --- /dev/null +++ b/src/sysext_utils/commands.py @@ -0,0 +1,216 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import subprocess + +from . import config + +from .errors import CommandNotAvailableError, CommandFailedError +from .definitions import DryStepType + + +class BaseCommand: + @classmethod + def run(cls, *args) -> None: + raise NotImplementedError() + + @classmethod + def do_run(cls, command: str, *args) -> None: + commands = [command] + list(*args) + + if config.dry_run: + print([str(DryStepType.RUNNING)] + commands) + return + + try: + subprocess.run( + commands, + stdout=None if config.verbose else subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + ) + except FileNotFoundError: + raise CommandNotAvailableError(command) + except subprocess.CalledProcessError as e: + raise CommandFailedError(command, e.stdout) + + +class GLibCompileSchemas(BaseCommand): + @classmethod + def compile(cls, source: str, destination: str) -> None: + cls.run( + [ + "--strict", + source, + "--targetdir", + destination, + ] + ) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("glib-compile-schemas", *args) + + +class Gtk4UpdateIconCache(BaseCommand): + @classmethod + def update(cls, path: str) -> None: + cls.run( + [ + "--quiet", + "--ignore-theme-index", + "--force", + path, + ] + ) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("gtk4-update-icon-cache", *args) + + +class Importctl(BaseCommand): + @classmethod + def import_raw(cls, name: str, image: str) -> None: + cls.run( + [ + "import-raw", + "--class=sysext", + image, + name, + ] + ) + + @classmethod + def pull_raw(cls, name: str, url: str) -> None: + cls.run( + [ + "pull-raw", + "--verify=no", + "--class=sysext", + url, + name, + ] + ) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("importctl", *args) + + +class Mksquashfs(BaseCommand): + @classmethod + def build( + cls, + source: str, + path: str, + ) -> None: + cls.run([source, path]) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("mksquashfs", *args) + + +class SystemdRepart(BaseCommand): + @classmethod + def build( + cls, + source: str, + path: str, + private_key: str, + certificate: str, + ) -> None: + cls.run( + [ + "--make-ddi=sysext", + "--offline=true", + "--seed=random", + f"--private-key={private_key}", + f"--certificate={certificate}", + f"--copy-source={source}", + path, + ] + ) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("systemd-repart", *args) + + +class SystemdSysext(BaseCommand): + @classmethod + def refresh(cls) -> None: + cls.run(["refresh"]) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("systemd-sysext", *args) + + +class Bst(BaseCommand): + @classmethod + def build(cls, workspace: str) -> None: + cls.run( + [ + "--directory", + workspace, + "build", + "--retry-failed", + ] + ) + + @classmethod + def artifact_checkout(cls, workspace: str, directory: str) -> None: + cls.run( + [ + "--directory", + workspace, + "artifact", + "checkout", + "--force", + "--deps=none", + "--directory", + directory, + ] + ) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("bst", *args) + + +class Meson(BaseCommand): + @classmethod + def compile(cls, build_directory: str) -> None: + cls.run(["compile", "-C", build_directory]) + + @classmethod + def install(cls, build_directory: str, destination: str) -> None: + cls.run(["install", "-C", build_directory, "--destdir", destination]) + + @classmethod + def run(cls, *args) -> None: + cls.do_run("meson", *args) diff --git a/src/sysext_utils/config.py b/src/sysext_utils/config.py new file mode 100644 index 0000000000000000000000000000000000000000..3ee08b65a6da2b1032f4a59dfe329b8792dcbf11 --- /dev/null +++ b/src/sysext_utils/config.py @@ -0,0 +1,27 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +dry_run = False +verbose = False diff --git a/src/sysext_utils/definitions.py b/src/sysext_utils/definitions.py new file mode 100644 index 0000000000000000000000000000000000000000..4128592c349e63e2125d93293419c013dc779352 --- /dev/null +++ b/src/sysext_utils/definitions.py @@ -0,0 +1,63 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import os + +from enum import StrEnum + +OS_RELEASE_PATH = os.path.join(os.sep, "etc", "os-release") +RUN_EXTENSIONS_DIR = os.path.join(os.sep, "run", "extensions") +VAR_EXTENSIONS_DIR = os.path.join(os.sep, "var", "lib", "extensions") +SCHEMAS_DIR = os.path.join("usr", "share", "glib-2.0", "schemas") +ICONS_DIR = os.path.join("usr", "share", "icons", "hicolor") +ICONS_CACHE_FILENAME = "icon-theme.cache" +METADATA_DIR = os.path.join("usr", "lib", "extension-release.d") +METADATA_PREFIX = "extension-release." +METADATA_CUSTOM_FIELD = "SYSEXT_UTILS_VERSION" +SYSEXT_IMAGE_SUFFIX = ".sysext.raw" +WORKSPACE_DIR = os.path.join(os.getcwd(), ".sysext-utils") +INTEGRATION_SYSEXT_NAME = "sysext-utils-integration" + + +class ImageFormat(StrEnum): + DDI = "ddi" + COMPRESSED = "compressed" + + +class DryStepType(StrEnum): + RUNNING = "running" + MAKING = "making" + WRITING = "writing" + MOVING = "moving" + REMOVING = "removing" + COPYING = "copying" + + +class BuildSystemType(StrEnum): + AUTO = "auto" + BST = "bst" + MESON = "meson" + IMPORT = "import" + UNKOWN = "unknown" diff --git a/src/sysext_utils/errors.py b/src/sysext_utils/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..41ea73a60d0f0b3efe8ccaef7042f9282d42e5cf --- /dev/null +++ b/src/sysext_utils/errors.py @@ -0,0 +1,70 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from typing import Optional + + +class HandledError(BaseException): + pass + + +class NotPriviledgedError(HandledError): + def __init__(self): + super().__init__("Need to be privileged.") + + +class CommandNotAvailableError(HandledError): + def __init__(self, command: str): + super().__init__(f"{command} is not installed.") + + +class CommandFailedError(HandledError): + def __init__(self, command: str, output: Optional[bytes]): + if output is not None: + message = f"{command} failed due to: {output.decode('UTF-8')}" + else: + message = f"{command} failed." + + super().__init__(message) + + +class EmptyDirectoryError(HandledError): + def __init__(self, directory: str): + super().__init__(f"No contents were found at: {directory}") + + +class UnsupportedImageFormatError(HandledError): + def __init__(self, image_format: str): + super().__init__(f"Image format not supported: {image_format}") + + +class UnsupportedBuildSystemError(HandledError): + def __init__(self, system: str): + super().__init__(f"Build system not supported: {system}") + + +class IncompatibleSettingError(HandledError): + def __init__(self): + super().__init__("Extensions must be either all persistent or runtime") diff --git a/src/sysext_utils/helpers.py b/src/sysext_utils/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..299ecd5cd2894bce7521605e5cc843e472242de3 --- /dev/null +++ b/src/sysext_utils/helpers.py @@ -0,0 +1,218 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import os +import re +import shutil + +from urllib.parse import urlparse +from typing import Dict, List, Tuple + +from . import config + +from .version import version +from .errors import ( + NotPriviledgedError, + EmptyDirectoryError, + IncompatibleSettingError, +) +from .definitions import ( + RUN_EXTENSIONS_DIR, + VAR_EXTENSIONS_DIR, + METADATA_DIR, + METADATA_PREFIX, + WORKSPACE_DIR, + OS_RELEASE_PATH, + SYSEXT_IMAGE_SUFFIX, + METADATA_CUSTOM_FIELD, + INTEGRATION_SYSEXT_NAME, + BuildSystemType, + DryStepType, +) + + +def get_release_data(path: str) -> Dict[str, str]: + data = {} + + try: + with open(path, encoding="utf-8") as f: + data = dict([l.strip().split("=", 1) for l in f.readlines()]) + except FileNotFoundError: + pass + + return data + + +def get_sysext_metadata(ignore_release: bool) -> str: + metadata = { + f"{METADATA_CUSTOM_FIELD}": version, + } + + if ignore_release: + metadata["ID"] = "_any" + else: + release = get_release_data(OS_RELEASE_PATH) + metadata["ID"] = release.get("ID", "_any") + metadata["VERSION_ID"] = release.get("VERSION_ID", "_any") + + return "\n".join([f"{k}={v}" for k, v in metadata.items()]) + + +def get_active_sysext_names() -> List[str]: + names: List[str] = [] + + path = os.path.join(os.sep, METADATA_DIR) + + if not os.path.exists(path): + return names + + for filename in os.listdir(path): + release = get_release_data(os.path.join(path, filename)) + + if not release.get(METADATA_CUSTOM_FIELD): + continue + + names.append(filename.removeprefix(METADATA_PREFIX)) + + return names + + +def is_url(string: str) -> bool: + return re.match("http[s]?://", string) is not None + + +def guess_system_from_source(source: str) -> str: + if os.path.exists(os.path.join(source, "meson-info")): + return BuildSystemType.MESON + elif os.path.exists(os.path.join(source, ".bstproject.yaml")): + return BuildSystemType.BST + elif os.path.exists(os.path.join(source, "usr")): + return BuildSystemType.IMPORT + + return BuildSystemType.UNKOWN + + +def get_path_for_input_sysext_name(name: str, directory: str) -> str: + return os.path.join(directory, f"{name}.sysext.raw") + + +def get_name_from_input_sysext_image(image: str) -> str: + if is_url(image): + filename = os.path.basename(urlparse(image).path) + else: + filename = os.path.basename(image) + + return filename.removesuffix(SYSEXT_IMAGE_SUFFIX) + + +def get_tree_path_for_active_sysext_name(name: str, persistent: bool) -> str: + if persistent: + return os.path.join(VAR_EXTENSIONS_DIR, name) + + return os.path.join(RUN_EXTENSIONS_DIR, name) + + +def get_tree_setting_for_sysext_name(name: str) -> Tuple[bool, bool]: + if os.path.exists(get_tree_path_for_active_sysext_name(name, persistent=True)): + return True, True + elif os.path.exists(get_tree_path_for_active_sysext_name(name, persistent=False)): + return True, False + + return False, False + + +def get_image_path_for_active_sysext_name(name: str, persistent: bool) -> str: + return get_tree_path_for_active_sysext_name(name, persistent) + ".raw" + + +def get_path_for_artifact() -> str: + return os.path.join(WORKSPACE_DIR, "artifact") + + +def get_path_for_integration_artifact() -> str: + return os.path.join(WORKSPACE_DIR, "integration") + + +def make_directory(directory: str) -> None: + if config.dry_run: + print([str(DryStepType.MAKING), directory]) + else: + os.makedirs(directory, exist_ok=True) + + +def copy_directory(source: str, destination: str) -> None: + if config.dry_run: + print([str(DryStepType.COPYING), source, destination]) + else: + shutil.copytree(source, destination, dirs_exist_ok=True) + + +def remove_directory(directory: str) -> None: + if config.dry_run: + print([str(DryStepType.REMOVING), directory]) + else: + shutil.rmtree(directory, ignore_errors=True) + + +def write_file(content: str, path: str) -> None: + if config.dry_run: + print([str(DryStepType.WRITING), content, path]) + else: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def move_file(source: str, destination: str) -> None: + if config.dry_run: + print([str(DryStepType.MOVING), source, destination]) + else: + shutil.move(source, destination) + + +def remove_file(path: str) -> None: + if config.dry_run: + print([str(DryStepType.REMOVING), path]) + else: + try: + os.remove(path) + except FileNotFoundError: + pass + + +def ensure_priviledged() -> None: + if not config.dry_run and os.getuid() != 0: + raise NotPriviledgedError() + + +def ensure_directory(path: str) -> None: + if not config.dry_run and (not os.path.isdir(path) or not os.listdir(path)): + raise EmptyDirectoryError(path) + + +def ensure_setting(persistent: bool) -> None: + exists, _persistent = get_tree_setting_for_sysext_name(INTEGRATION_SYSEXT_NAME) + + if exists and _persistent != persistent: + raise IncompatibleSettingError() diff --git a/src/sysext_utils/main.py b/src/sysext_utils/main.py new file mode 100644 index 0000000000000000000000000000000000000000..38b5e9e38bca3a6fb6e854916d2e4a74a11f191b --- /dev/null +++ b/src/sysext_utils/main.py @@ -0,0 +1,272 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import os +import re +import sys +import argparse + +from . import config +from .errors import HandledError +from .definitions import ImageFormat, BuildSystemType, WORKSPACE_DIR +from .version import version +from .workflows import ( + run_image_build_workflow, + run_image_add_workflow, + run_image_remove_workflow, + run_cleanup_workflow, +) + + +def _name_validator(name: str) -> str: + if not re.match("^[\\w\\-.]+$", name): + raise argparse.ArgumentTypeError(f"'{name}' is not a valid sysext name") + return name + + +def _build_args_validator( + parser: argparse.ArgumentParser, + args: argparse.Namespace, +) -> None: + if args.format == ImageFormat.DDI and not (args.private_key and args.certificate): + parser.error("DDI image creation require private_key and certificate") + + +def _build_base_parser(name: str, description: str) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=name, + description=description, + ) + parser.add_argument( + "--version", + help="print the current version of this utility", + action="version", + version=version, + ) + parser.add_argument( + "--dry", + help="prints to stdout the commands involved in the operation", + action="store_true", + ) + parser.add_argument( + "--verbose", + help="captures all system calls stdout and stderr", + action="store_true", + ) + + return parser + + +def _handle_global_args( + parser: argparse.ArgumentParser, + args: argparse.Namespace, +) -> None: + config.dry_run = args.dry + config.verbose = args.verbose + + +def add(): + parser = _build_base_parser("sysext-add", "add an image to the system") + parser.add_argument( + "image", + help="path or URL to the sysext image e.g., sdk.sysext.raw", + ) + parser.add_argument( + "--persistent", + help="add the image under /var/lib/extensions/ to persist after reboots", + action="store_true", + ) + + args = parser.parse_args() + _handle_global_args(parser, args) + + try: + run_image_add_workflow(args.image, args.persistent) + except HandledError as e: + sys.exit(str(e)) + finally: + run_cleanup_workflow() + + +def remove(): + parser = _build_base_parser("sysext-remove", "remove an image from the system") + parser.add_argument( + "name", + help="name of the the sysext e.g., sdk", + type=_name_validator, + ) + + args = parser.parse_args() + _handle_global_args(parser, args) + + try: + run_image_remove_workflow(args.name) + except HandledError as e: + sys.exit(str(e)) + + +def build(): + parser = _build_base_parser("sysext-build", "build an image from a given directory") + parser.add_argument( + "name", + help="what to name the sysext e.g., sdk", + type=_name_validator, + ) + parser.add_argument( + "source", + help="directory to build the image from e.g., ./destdir/", + default=os.path.abspath(os.getcwd()), + ) + parser.add_argument( + "--destination", + help="directory to write the image to e.g., ./images/. Defaults to $PWD", + default=os.path.abspath(os.getcwd()), + ) + parser.add_argument( + "--format", + help="format to be used for the image creation", + choices=[str(ImageFormat.DDI), str(ImageFormat.COMPRESSED)], + default=ImageFormat.COMPRESSED, + ) + parser.add_argument( + "--private_key", + help="path to the private key file e.g., files/boot-keys/SYSEXT.key", + default="", + ) + parser.add_argument( + "--certificate", + help="path to the certificate file, e.g., files/boot-keys/SYSEXT.crt", + default="", + ) + parser.add_argument( + "--ignore-release", + help="ignore the host release data and use 'ID=_any' instead", + action="store_true", + ) + parser.add_argument( + "--system", + help="build system to use e.g., import", + choices=[ + str(BuildSystemType.AUTO), + str(BuildSystemType.BST), + str(BuildSystemType.MESON), + str(BuildSystemType.IMPORT), + ], + default=BuildSystemType.AUTO, + ) + + args = parser.parse_args() + _handle_global_args(parser, args) + _build_args_validator(parser, args) + + try: + run_image_build_workflow( + args.name, + args.source, + args.destination, + args.format, + args.private_key, + args.certificate, + args.ignore_release, + args.system, + ) + except HandledError as e: + sys.exit(str(e)) + finally: + run_cleanup_workflow() + + +def install(): + parser = _build_base_parser("sysext-install", "build and add an image") + parser.add_argument( + "name", + help="what to name the sysext e.g., sdk", + type=_name_validator, + ) + parser.add_argument( + "source", + help="directory to build the image from e.g., ./destdir/", + default=os.path.abspath(os.getcwd()), + ) + parser.add_argument( + "--format", + help="format to be used for the image creation", + choices=[str(ImageFormat.DDI), str(ImageFormat.COMPRESSED)], + default=ImageFormat.COMPRESSED, + ) + parser.add_argument( + "--private_key", + help="path to the private key file e.g., files/boot-keys/SYSEXT.key", + default="", + ) + parser.add_argument( + "--certificate", + help="path to the certificate file, e.g., files/boot-keys/SYSEXT.crt", + default="", + ) + parser.add_argument( + "--ignore-release", + help="ignore the host release data and use 'ID=_any' instead", + action="store_true", + ) + parser.add_argument( + "--persistent", + help="add the image under /var/lib/extensions/ to persist after reboots", + action="store_true", + ) + parser.add_argument( + "--system", + help="build system to use e.g., import", + choices=[ + str(BuildSystemType.AUTO), + str(BuildSystemType.BST), + str(BuildSystemType.MESON), + str(BuildSystemType.IMPORT), + ], + default=BuildSystemType.AUTO, + ) + + args = parser.parse_args() + _handle_global_args(parser, args) + _build_args_validator(parser, args) + + try: + run_image_add_workflow( + run_image_build_workflow( + args.name, + args.source, + WORKSPACE_DIR, + args.format, + args.private_key, + args.certificate, + args.ignore_release, + args.system, + ), + args.persistent, + ) + except HandledError as e: + sys.exit(str(e)) + finally: + run_cleanup_workflow() diff --git a/src/sysext_utils/utilities.py b/src/sysext_utils/utilities.py new file mode 100644 index 0000000000000000000000000000000000000000..4b104b0a8bb0eab16642e27b0850c4638e262c4b --- /dev/null +++ b/src/sysext_utils/utilities.py @@ -0,0 +1,216 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import os + +from .errors import UnsupportedImageFormatError, UnsupportedBuildSystemError +from .definitions import ( + ImageFormat, + BuildSystemType, + RUN_EXTENSIONS_DIR, + SCHEMAS_DIR, + ICONS_DIR, + ICONS_CACHE_FILENAME, + METADATA_DIR, + METADATA_PREFIX, +) +from .commands import ( + SystemdRepart, + Mksquashfs, + Importctl, + SystemdSysext, + GLibCompileSchemas, + Gtk4UpdateIconCache, + Bst, + Meson, +) +from .helpers import ( + get_sysext_metadata, + is_url, + get_image_path_for_active_sysext_name, + get_tree_path_for_active_sysext_name, + get_path_for_input_sysext_name, + get_path_for_artifact, + get_path_for_integration_artifact, + guess_system_from_source, + make_directory, + copy_directory, + remove_directory, + ensure_directory, + write_file, + move_file, + remove_file, +) + + +def build_image( + name: str, + artifact: str, + destination: str, + image_format: str, + private_key: str, + certificate: str, + ignore_release: bool, +) -> str: + image = get_path_for_input_sysext_name(name, destination) + + remove_file(image) + make_directory(destination) + + build_tree(name, artifact, ignore_release) + + if image_format == ImageFormat.DDI: + SystemdRepart.build(artifact, image, private_key, certificate) + elif image_format == ImageFormat.COMPRESSED: + Mksquashfs.build(artifact, image) + else: + raise UnsupportedImageFormatError(image_format) + + return image + + +def build_tree(name: str, artifact: str, ignore_release: bool) -> None: + metadata_directory = os.path.join(artifact, METADATA_DIR) + metadata_path = os.path.join(metadata_directory, f"{METADATA_PREFIX}{name}") + metadata_content = get_sysext_metadata(ignore_release) + + make_directory(metadata_directory) + write_file(metadata_content, metadata_path) + + +def add_image(name: str, image: str, persistent: bool) -> None: + if is_url(image): + Importctl.pull_raw(name, image) + else: + Importctl.import_raw(name, image) + + # See https://github.com/systemd/systemd/issues/32938 + if not persistent: + make_directory(RUN_EXTENSIONS_DIR) + move_file( + get_image_path_for_active_sysext_name(name, persistent=True), + get_image_path_for_active_sysext_name(name, persistent=False), + ) + + SystemdSysext.refresh() + + +def add_tree(name: str, artifact: str, persistent: bool) -> None: + copy_directory( + artifact, + get_tree_path_for_active_sysext_name(name, persistent=persistent), + ) + + SystemdSysext.refresh() + + +def remove_image(name: str) -> None: + remove_file(get_image_path_for_active_sysext_name(name, persistent=True)) + remove_file(get_image_path_for_active_sysext_name(name, persistent=False)) + + SystemdSysext.refresh() + + +def remove_tree(name: str) -> None: + remove_directory(get_tree_path_for_active_sysext_name(name, persistent=True)) + remove_directory(get_tree_path_for_active_sysext_name(name, persistent=False)) + + SystemdSysext.refresh() + + +def build_integration_artifact() -> str: + artifact = get_path_for_integration_artifact() + + remove_directory(artifact) + make_directory(artifact) + + build_integration_schemas(artifact) + build_integration_icons(artifact) + + ensure_directory(artifact) + + return artifact + + +def build_integration_schemas(artifact: str) -> None: + host_schemas_dir = os.path.join(os.sep, SCHEMAS_DIR) + artifact_schemas_dir = os.path.join(artifact, SCHEMAS_DIR) + + make_directory(artifact_schemas_dir) + GLibCompileSchemas.compile(host_schemas_dir, artifact_schemas_dir) + + +def build_integration_icons(artifact: str) -> None: + host_icons_dir = os.path.join(os.sep, ICONS_DIR) + tmp_icons_dir = os.path.join(artifact, ICONS_DIR + ".tmp") + artifact_icons_dir = os.path.join(artifact, ICONS_DIR) + + make_directory(tmp_icons_dir) + copy_directory(host_icons_dir, tmp_icons_dir) + Gtk4UpdateIconCache.update(tmp_icons_dir) + + make_directory(artifact_icons_dir) + move_file( + os.path.join(tmp_icons_dir, ICONS_CACHE_FILENAME), + os.path.join(artifact_icons_dir, ICONS_CACHE_FILENAME), + ) + remove_directory(tmp_icons_dir) + + +def build_artifact(source: str, system: str) -> str: + destination = get_path_for_artifact() + + remove_directory(destination) + make_directory(destination) + + if system == BuildSystemType.AUTO: + system = guess_system_from_source(source) + if system == BuildSystemType.BST: + build_artifact_bst_elements(source, destination) + elif system == BuildSystemType.MESON: + build_artifact_meson_project(source, destination) + elif system == BuildSystemType.IMPORT: + build_artifact_import_directory(source, destination) + else: + raise UnsupportedBuildSystemError(system) + + ensure_directory(destination) + + return destination + + +def build_artifact_bst_elements(workspace: str, directory: str) -> None: + Bst.build(workspace) + Bst.artifact_checkout(workspace, directory) + + +def build_artifact_meson_project(build_directory: str, destination: str) -> None: + Meson.compile(build_directory) + Meson.install(build_directory, destination) + + +def build_artifact_import_directory(source: str, destination: str) -> None: + ensure_directory(source) + copy_directory(source, destination) diff --git a/src/sysext_utils/workflows.py b/src/sysext_utils/workflows.py new file mode 100644 index 0000000000000000000000000000000000000000..1bea6b820b79697b79c580fad8bea29da7282af1 --- /dev/null +++ b/src/sysext_utils/workflows.py @@ -0,0 +1,110 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from .definitions import WORKSPACE_DIR, INTEGRATION_SYSEXT_NAME +from .helpers import ( + get_name_from_input_sysext_image, + get_tree_setting_for_sysext_name, + get_active_sysext_names, + ensure_priviledged, + ensure_setting, + remove_directory, +) +from .utilities import ( + build_image, + add_image, + remove_image, + build_artifact, + build_tree, + add_tree, + remove_tree, + build_integration_artifact, +) + + +def run_image_build_workflow( + name: str, + source: str, + destination: str, + image_format: str, + private_key: str, + certificate: str, + ignore_release: bool, + system: str, +) -> str: + artifact = build_artifact(source, system) + image = build_image( + name, + artifact, + destination, + image_format, + private_key, + certificate, + ignore_release, + ) + + print(f"Successfully built {image}") + + return image + + +def run_image_integration_workflow(persistent: bool) -> None: + remove_tree(INTEGRATION_SYSEXT_NAME) + artifact = build_integration_artifact() + build_tree(INTEGRATION_SYSEXT_NAME, artifact, False) + add_tree(INTEGRATION_SYSEXT_NAME, artifact, persistent) + + +def run_image_add_workflow(image: str, persistent: bool) -> None: + name = get_name_from_input_sysext_image(image) + + ensure_priviledged() + ensure_setting(persistent) + remove_image(name) + add_image(name, image, persistent) + run_image_integration_workflow(persistent) + + print(f"Successfully added {image}") + + +def run_image_deintegration_workflow() -> None: + exists, persistent = get_tree_setting_for_sysext_name(INTEGRATION_SYSEXT_NAME) + + if exists: + remove_tree(INTEGRATION_SYSEXT_NAME) + if get_active_sysext_names(): + run_image_integration_workflow(persistent) + + +def run_image_remove_workflow(name: str) -> None: + ensure_priviledged() + remove_image(name) + run_image_deintegration_workflow() + + print(f"Successfully removed {name}") + + +def run_cleanup_workflow() -> None: + remove_directory(WORKSPACE_DIR) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..adbf5803c27089171ffc1a9d9940a162b7565b2b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,24 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT diff --git a/tests/data/example/usr/bin/works b/tests/data/example/usr/bin/works new file mode 100755 index 0000000000000000000000000000000000000000..6744650cd5aca9700ded6619b11e78ce3cadcb80 --- /dev/null +++ b/tests/data/example/usr/bin/works @@ -0,0 +1 @@ +echo "yes" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000000000000000000000000000000000000..73e0c7e7728083bc51f895cbaedcfb082bb721ad --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,94 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import pytest + +from sysext_utils import config +from sysext_utils.errors import ( + CommandNotAvailableError, + CommandFailedError, + EmptyDirectoryError, + UnsupportedImageFormatError, + UnsupportedBuildSystemError, +) +from sysext_utils.helpers import ensure_directory +from sysext_utils.commands import BaseCommand +from sysext_utils.utilities import build_image, build_artifact + + +class NoExistentCommand(BaseCommand): + @classmethod + def run(cls, *args) -> None: + cls.do_run("non-existent-command", *args) + + +class AlwaysFailsCommand(BaseCommand): + @classmethod + def run(cls, *args) -> None: + cls.do_run("false", *args) + + +def test_command_not_available_error(): + with pytest.raises(CommandNotAvailableError) as _: + NoExistentCommand.run([]) + + +def test_command_failed_error(): + with pytest.raises(CommandFailedError) as _: + AlwaysFailsCommand.run([]) + + +def test_empty_directory_error(): + with pytest.raises(EmptyDirectoryError) as _: + ensure_directory("does_not_exists") + + +def test_unsupported_image_format_error(): + config.dry_run = True + + with pytest.raises(UnsupportedImageFormatError) as _: + build_image( + name="name", + artifact="artifact", + destination="destination", + image_format="BOGUS", + private_key=None, + certificate=None, + ignore_release=True, + ) + + config.dry_run = False + + +def test_unsupported_build_system_error(): + config.dry_run = True + + with pytest.raises(UnsupportedBuildSystemError) as _: + build_artifact( + source="source", + system="BOGUS", + ) + + config.dry_run = False diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000000000000000000000000000000000000..70220ba21b2c7b0805965d330910c3e20c85edf0 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,121 @@ +# MIT License +# +# Copyright (c) 2024 Codethink Limited +# Copyright (c) 2024 GNOME Foundation +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from sysext_utils import config +from sysext_utils.definitions import ImageFormat, BuildSystemType +from sysext_utils.workflows import ( + run_image_build_workflow, + run_image_add_workflow, + run_image_remove_workflow, +) + + +def setup_module(): + config.dry_run = True + + +def test_image_build_workflow(): + run_image_build_workflow( + name="name", + source="source", + destination="destination", + image_format=ImageFormat.DDI, + private_key="private_key", + certificate="certificate", + ignore_release=False, + system=BuildSystemType.BST, + ) + + +def test_image_build_workflow_with_import(): + run_image_build_workflow( + name="name", + source="source", + destination="destination", + image_format=ImageFormat.DDI, + private_key="private_key", + certificate="certificate", + ignore_release=False, + system=BuildSystemType.IMPORT, + ) + + +def test_image_build_workflow_with_meson(): + run_image_build_workflow( + name="name", + source="source", + destination="destination", + image_format=ImageFormat.DDI, + private_key="private_key", + certificate="certificate", + ignore_release=False, + system=BuildSystemType.MESON, + ) + + +def test_image_build_workflow_with_compressed_format(): + run_image_build_workflow( + name="name", + source="source", + destination="destination", + image_format=ImageFormat.COMPRESSED, + private_key="private_key", + certificate="certificate", + ignore_release=False, + system=BuildSystemType.MESON, + ) + + +def test_image_build_work_with_ignore_release(): + run_image_build_workflow( + name="name", + source="source", + destination="destination", + image_format=ImageFormat.COMPRESSED, + private_key="private_key", + certificate="certificate", + ignore_release=True, + system=BuildSystemType.MESON, + ) + + +def test_image_add_workflow(): + run_image_add_workflow(image="image", persistent=False) + + +def test_image_add_workflow_with_remote_image(): + run_image_add_workflow(image="https://image", persistent=False) + + +def test_image_add_workflow_with_persistent_flag(): + run_image_add_workflow(image="image", persistent=True) + + +def test_image_add_workflow_without_integrations(): + run_image_add_workflow(image="image", persistent=False) + + +def test_image_remove_workflow(): + run_image_remove_workflow(name="name")