mirror of
				https://github.com/f-droid/fdroidserver.git
				synced 2025-11-04 06:30:27 +03:00 
			
		
		
		
	Merge branch 'buildbot-subcommands-up-destroy-exec-push-pull' into 'master'
set up new mechanism for unlisted subcommands; implement up/destroy/exec/push/pull See merge request fdroid/fdroidserver!1709
This commit is contained in:
		
						commit
						8667ebc56a
					
				
					 15 changed files with 1465 additions and 5 deletions
				
			
		| 
						 | 
					@ -852,3 +852,24 @@ PUBLISH:
 | 
				
			||||||
    - fdroid gpgsign --verbose
 | 
					    - fdroid gpgsign --verbose
 | 
				
			||||||
    - fdroid signindex --verbose
 | 
					    - fdroid signindex --verbose
 | 
				
			||||||
    - rsync --stats repo/* $serverwebroot/
 | 
					    - rsync --stats repo/* $serverwebroot/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# This tests the `podman system service` auto-starting and other general podman things.
 | 
				
			||||||
 | 
					podman system service:
 | 
				
			||||||
 | 
					  rules:
 | 
				
			||||||
 | 
					    - changes:
 | 
				
			||||||
 | 
					        - .gitlab-ci.yml
 | 
				
			||||||
 | 
					        - fdroidserver/up.py
 | 
				
			||||||
 | 
					        - tests/test_up.py
 | 
				
			||||||
 | 
					  image: debian:trixie-slim
 | 
				
			||||||
 | 
					  <<: *apt-template
 | 
				
			||||||
 | 
					  script:
 | 
				
			||||||
 | 
					    - apt-get install
 | 
				
			||||||
 | 
					        python3-asn1crypto
 | 
				
			||||||
 | 
					        python3-defusedxml
 | 
				
			||||||
 | 
					        python3-git
 | 
				
			||||||
 | 
					        python3-pillow
 | 
				
			||||||
 | 
					        python3-podman
 | 
				
			||||||
 | 
					        python3-ruamel.yaml
 | 
				
			||||||
 | 
					        python3-yaml
 | 
				
			||||||
 | 
					    - python3 -m unittest tests/test_up.py --verbose
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -55,6 +55,23 @@ COMMANDS = OrderedDict([
 | 
				
			||||||
    ("mirror", _("Download complete mirrors of small repos")),
 | 
					    ("mirror", _("Download complete mirrors of small repos")),
 | 
				
			||||||
])
 | 
					])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# The list of subcommands that are not advertised as public api,
 | 
				
			||||||
 | 
					# intended for the use-case of breaking down builds into atomic steps.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# For the build automation, there will be lots of subcommands that are
 | 
				
			||||||
 | 
					# meant to run in scripted environments.  They are not intended for
 | 
				
			||||||
 | 
					# interactive use.  Although they sometimes might be useful
 | 
				
			||||||
 | 
					# interactively, they might also sometimes be dangerous to run
 | 
				
			||||||
 | 
					# interactively because they rely on the presense of a VM/container,
 | 
				
			||||||
 | 
					# modify the local environment, or even run things as root.
 | 
				
			||||||
 | 
					COMMANDS_INTERNAL = [
 | 
				
			||||||
 | 
					    "destroy",
 | 
				
			||||||
 | 
					    "exec",
 | 
				
			||||||
 | 
					    "pull",
 | 
				
			||||||
 | 
					    "push",
 | 
				
			||||||
 | 
					    "up",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def print_help(available_plugins=None):
 | 
					def print_help(available_plugins=None):
 | 
				
			||||||
    print(_("usage: ") + _("fdroid [<command>] [-h|--help|--version|<args>]"))
 | 
					    print(_("usage: ") + _("fdroid [<command>] [-h|--help|--version|<args>]"))
 | 
				
			||||||
| 
						 | 
					@ -136,7 +153,12 @@ def main():
 | 
				
			||||||
        sys.exit(0)
 | 
					        sys.exit(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    command = sys.argv[1]
 | 
					    command = sys.argv[1]
 | 
				
			||||||
    if command not in COMMANDS and command not in available_plugins:
 | 
					    command_not_found = (
 | 
				
			||||||
 | 
					        command not in COMMANDS
 | 
				
			||||||
 | 
					        and command not in COMMANDS_INTERNAL
 | 
				
			||||||
 | 
					        and command not in available_plugins
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if command_not_found:
 | 
				
			||||||
        if command in ('-h', '--help'):
 | 
					        if command in ('-h', '--help'):
 | 
				
			||||||
            print_help(available_plugins=available_plugins)
 | 
					            print_help(available_plugins=available_plugins)
 | 
				
			||||||
            sys.exit(0)
 | 
					            sys.exit(0)
 | 
				
			||||||
| 
						 | 
					@ -186,7 +208,7 @@ def main():
 | 
				
			||||||
    sys.argv[0] += ' ' + command
 | 
					    sys.argv[0] += ' ' + command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    del sys.argv[1]
 | 
					    del sys.argv[1]
 | 
				
			||||||
    if command in COMMANDS.keys():
 | 
					    if command in COMMANDS.keys() or command in COMMANDS_INTERNAL:
 | 
				
			||||||
        # import is named import_subcommand internally b/c import is reserved by Python
 | 
					        # import is named import_subcommand internally b/c import is reserved by Python
 | 
				
			||||||
        command = 'import_subcommand' if command == 'import' else command
 | 
					        command = 'import_subcommand' if command == 'import' else command
 | 
				
			||||||
        mod = __import__('fdroidserver.' + command, None, None, [command])
 | 
					        mod = __import__('fdroidserver.' + command, None, None, [command])
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,10 +4,10 @@
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
# Copyright (C) 2010-2016, Ciaran Gultnieks, ciaran@ciarang.com
 | 
					# Copyright (C) 2010-2016, Ciaran Gultnieks, ciaran@ciarang.com
 | 
				
			||||||
# Copyright (C) 2013-2017, Daniel Martí <mvdan@mvdan.cc>
 | 
					# Copyright (C) 2013-2017, Daniel Martí <mvdan@mvdan.cc>
 | 
				
			||||||
# Copyright (C) 2013-2021, Hans-Christoph Steiner <hans@eds.org>
 | 
					# Copyright (C) 2013-2025, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
# Copyright (C) 2017-2018, Torsten Grote <t@grobox.de>
 | 
					# Copyright (C) 2017-2018, Torsten Grote <t@grobox.de>
 | 
				
			||||||
# Copyright (C) 2017, tobiasKaminsky <tobias@kaminsky.me>
 | 
					# Copyright (C) 2017, tobiasKaminsky <tobias@kaminsky.me>
 | 
				
			||||||
# Copyright (C) 2017-2021, Michael Pöhn <michael.poehn@fsfe.org>
 | 
					# Copyright (C) 2017-2025, Michael Pöhn <michael.poehn@fsfe.org>
 | 
				
			||||||
# Copyright (C) 2017,2021, mimi89999 <michel@lebihan.pl>
 | 
					# Copyright (C) 2017,2021, mimi89999 <michel@lebihan.pl>
 | 
				
			||||||
# Copyright (C) 2019-2021, Jochen Sprickerhof <git@jochen.sprickerhof.de>
 | 
					# Copyright (C) 2019-2021, Jochen Sprickerhof <git@jochen.sprickerhof.de>
 | 
				
			||||||
# Copyright (C) 2021, Felix C. Stegerman <flx@obfusk.net>
 | 
					# Copyright (C) 2021, Felix C. Stegerman <flx@obfusk.net>
 | 
				
			||||||
| 
						 | 
					@ -67,6 +67,7 @@ import logging
 | 
				
			||||||
import operator
 | 
					import operator
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					import shlex
 | 
				
			||||||
import shutil
 | 
					import shutil
 | 
				
			||||||
import socket
 | 
					import socket
 | 
				
			||||||
import stat
 | 
					import stat
 | 
				
			||||||
| 
						 | 
					@ -82,7 +83,7 @@ from datetime import datetime, timedelta, timezone
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from queue import Queue
 | 
					from queue import Queue
 | 
				
			||||||
from typing import List
 | 
					from typing import List
 | 
				
			||||||
from urllib.parse import urlparse, urlsplit, urlunparse
 | 
					from urllib.parse import urlparse, urlsplit, urlunparse, unquote
 | 
				
			||||||
from zipfile import ZipFile
 | 
					from zipfile import ZipFile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import defusedxml.ElementTree as XMLElementTree
 | 
					import defusedxml.ElementTree as XMLElementTree
 | 
				
			||||||
| 
						 | 
					@ -108,6 +109,13 @@ from .looseversion import LooseVersion
 | 
				
			||||||
# The path to this fdroidserver distribution
 | 
					# The path to this fdroidserver distribution
 | 
				
			||||||
FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
 | 
					FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SUPPORTED_VIRT_CONTAINER_TYPES = ('podman', 'vagrant')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# The path to the homedir where apps are built in the container/VM.
 | 
				
			||||||
 | 
					BUILD_HOME = '/home/vagrant'
 | 
				
			||||||
 | 
					# Username inside of build containers/VMs used for runing builds.
 | 
				
			||||||
 | 
					BUILD_USER = 'vagrant'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# There needs to be a default, and this is the most common for software.
 | 
					# There needs to be a default, and this is the most common for software.
 | 
				
			||||||
DEFAULT_LOCALE = 'en-US'
 | 
					DEFAULT_LOCALE = 'en-US'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -280,6 +288,27 @@ def setup_global_opts(parser):
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def setup_virt_container_type_opts(parser):
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "--virt-container-type",
 | 
				
			||||||
 | 
					        choices=SUPPORTED_VIRT_CONTAINER_TYPES,
 | 
				
			||||||
 | 
					        help="Set the VM/container type used by the build process.",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_virt_container_type(options):
 | 
				
			||||||
 | 
					    if options.virt_container_type:
 | 
				
			||||||
 | 
					        return options.virt_container_type
 | 
				
			||||||
 | 
					    vct = get_config().get('virt_container_type')
 | 
				
			||||||
 | 
					    if vct not in SUPPORTED_VIRT_CONTAINER_TYPES:
 | 
				
			||||||
 | 
					        supported = ', '.join(sorted(SUPPORTED_VIRT_CONTAINER_TYPES))
 | 
				
			||||||
 | 
					        logging.error(
 | 
				
			||||||
 | 
					            f"'virt_container_type: {vct}' not supported, try: {supported}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					    return vct
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ColorFormatter(logging.Formatter):
 | 
					class ColorFormatter(logging.Formatter):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, msg):
 | 
					    def __init__(self, msg):
 | 
				
			||||||
| 
						 | 
					@ -1046,6 +1075,27 @@ def get_local_metadata_files():
 | 
				
			||||||
    return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
 | 
					    return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def split_pkg_arg(appid_versionCode_pair):
 | 
				
			||||||
 | 
					    """Split 'appid:versionCode' pair into 2 separate values safely.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :raises ValueError: if argument is not parseable
 | 
				
			||||||
 | 
					    :return: (appid, versionCode) tuple with the 2 parsed values
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    tokens = appid_versionCode_pair.split(":")
 | 
				
			||||||
 | 
					    if len(tokens) != 2:
 | 
				
			||||||
 | 
					        raise ValueError(
 | 
				
			||||||
 | 
					            _("'{}' is not a valid pair of the form appId:versionCode pair").format(
 | 
				
			||||||
 | 
					                appid_versionCode_pair
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    if not is_valid_package_name(tokens[0]):
 | 
				
			||||||
 | 
					        raise ValueError(
 | 
				
			||||||
 | 
					            _("'{}' does not start with a valid appId").format(appid_versionCode_pair)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    versionCode = version_code_string_to_int(tokens[1])
 | 
				
			||||||
 | 
					    return tokens[0], versionCode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def read_pkg_args(appid_versionCode_pairs, allow_version_codes=False):
 | 
					def read_pkg_args(appid_versionCode_pairs, allow_version_codes=False):
 | 
				
			||||||
    """No summary.
 | 
					    """No summary.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1259,6 +1309,16 @@ def get_source_date_epoch(build_dir):
 | 
				
			||||||
            return repo.git.log('-n1', '--pretty=%ct', '--', metadatapath)
 | 
					            return repo.git.log('-n1', '--pretty=%ct', '--', metadatapath)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_container_name(appid, vercode):
 | 
				
			||||||
 | 
					    """Return unique name for associating a build with a container or VM."""
 | 
				
			||||||
 | 
					    return f'{appid}_{vercode}'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_pod_name(appid, vercode):
 | 
				
			||||||
 | 
					    """Return unique name for associating a build with a Podman "pod"."""
 | 
				
			||||||
 | 
					    return f'{get_container_name(appid, vercode)}_pod'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_build_dir(app):
 | 
					def get_build_dir(app):
 | 
				
			||||||
    """Get the dir that this app will be built in."""
 | 
					    """Get the dir that this app will be built in."""
 | 
				
			||||||
    if app.RepoType == 'srclib':
 | 
					    if app.RepoType == 'srclib':
 | 
				
			||||||
| 
						 | 
					@ -5012,3 +5072,184 @@ FDROIDORG_MIRRORS = [
 | 
				
			||||||
FDROIDORG_FINGERPRINT = (
 | 
					FDROIDORG_FINGERPRINT = (
 | 
				
			||||||
    '43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB'
 | 
					    '43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB'
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_podman_client():
 | 
				
			||||||
 | 
					    """Return an instance of podman-py to work with Podman.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This assumes that it will use the default local UNIX Domain Socket
 | 
				
			||||||
 | 
					    to connect to the Podman service.  The preferred setup is to first
 | 
				
			||||||
 | 
					    do `systemctl --user start podman.socket` to keep the Podman UNIX
 | 
				
			||||||
 | 
					    socket running all the time.  If the socket is not present, this
 | 
				
			||||||
 | 
					    will automatically start the service, which has a default five
 | 
				
			||||||
 | 
					    second timeout.  After the timeout, it shuts itself down.  This is
 | 
				
			||||||
 | 
					    done outside of systemd so it will work where systemd is not
 | 
				
			||||||
 | 
					    installed.  The systemd socket calls the same command anyway.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    https://docs.podman.io/en/latest/markdown/podman-system-service.1.html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    import podman
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    client = podman.PodmanClient()
 | 
				
			||||||
 | 
					    url = client.api.base_url
 | 
				
			||||||
 | 
					    socket_path = unquote(url.netloc)
 | 
				
			||||||
 | 
					    if not os.path.exists(socket_path):
 | 
				
			||||||
 | 
					        logging.info(f'Starting podman system service at {socket_path}')
 | 
				
			||||||
 | 
					        subprocess.Popen(
 | 
				
			||||||
 | 
					            ['podman', 'system', 'service'],
 | 
				
			||||||
 | 
					            close_fds=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        retried = 0
 | 
				
			||||||
 | 
					        while not os.path.exists(socket_path):
 | 
				
			||||||
 | 
					            time.sleep(0.1)
 | 
				
			||||||
 | 
					            retried += 1
 | 
				
			||||||
 | 
					            if retried > 100:  # wait for ten seconds
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					    if not client.ping():
 | 
				
			||||||
 | 
					        path = f'{url.scheme}://{unquote(url.netloc)}'
 | 
				
			||||||
 | 
					        logging.error(f'No Podman service found at {path}!')
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					    return client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PODMAN_BUILDSERVER_IMAGE = 'registry.gitlab.com/fdroid/fdroidserver:buildserver'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_podman_container(appid, vercode):
 | 
				
			||||||
 | 
					    """Singleton getter, since podman-py is just an interface to the podman daemon singleton."""
 | 
				
			||||||
 | 
					    container_name = get_container_name(appid, vercode)
 | 
				
			||||||
 | 
					    client = get_podman_client()
 | 
				
			||||||
 | 
					    ret = None
 | 
				
			||||||
 | 
					    for c in client.containers.list(all=True):
 | 
				
			||||||
 | 
					        if c.name == container_name:
 | 
				
			||||||
 | 
					            ret = c
 | 
				
			||||||
 | 
					            break
 | 
				
			||||||
 | 
					    client.close()
 | 
				
			||||||
 | 
					    if ret is None:
 | 
				
			||||||
 | 
					        raise BuildException(f'Container for {appid}:{vercode} not found!')
 | 
				
			||||||
 | 
					    if PODMAN_BUILDSERVER_IMAGE not in ret.image.tags:
 | 
				
			||||||
 | 
					        raise BuildException(
 | 
				
			||||||
 | 
					            f'Container for {appid}:{vercode} has wrong image: {ret.image}'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return ret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def inside_exec(appid, vercode, command, virt_container_type, as_root=False):
 | 
				
			||||||
 | 
					    """Execute the command inside of the VM for the build."""
 | 
				
			||||||
 | 
					    if virt_container_type == 'vagrant':
 | 
				
			||||||
 | 
					        return vagrant_exec(appid, vercode, command, as_root)
 | 
				
			||||||
 | 
					    elif virt_container_type == 'podman':
 | 
				
			||||||
 | 
					        return podman_exec(appid, vercode, command, as_root)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        raise Exception(
 | 
				
			||||||
 | 
					            f"'{virt_container_type}' not supported, currently supported: vagrant, podman"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def podman_exec(appid, vercode, command, as_root=False):
 | 
				
			||||||
 | 
					    """Execute the command inside of a podman container for the build."""
 | 
				
			||||||
 | 
					    container_name = get_container_name(appid, vercode)
 | 
				
			||||||
 | 
					    to_stdin = shlex.join(command)
 | 
				
			||||||
 | 
					    user = 'root' if as_root else BUILD_USER
 | 
				
			||||||
 | 
					    p = subprocess.run(
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            'podman',
 | 
				
			||||||
 | 
					            'exec',
 | 
				
			||||||
 | 
					            '--interactive',
 | 
				
			||||||
 | 
					            f'--user={user}',
 | 
				
			||||||
 | 
					            f'--workdir={BUILD_HOME}',
 | 
				
			||||||
 | 
					            container_name,
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        + ['/bin/bash', '-e', '-l', '-x'],  # the shell that runs the command
 | 
				
			||||||
 | 
					        input=to_stdin,
 | 
				
			||||||
 | 
					        text=True,
 | 
				
			||||||
 | 
					        stderr=subprocess.STDOUT,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if p.returncode != 0:
 | 
				
			||||||
 | 
					        raise subprocess.CalledProcessError(
 | 
				
			||||||
 | 
					            p.returncode, f"{to_stdin} | {' '.join(p.args)}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_vagrantfile_path(appid, vercode):
 | 
				
			||||||
 | 
					    """Return the path for the unique VM for a given build.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Vagrant doesn't support ':' in VM names, so this uses '_' instead.
 | 
				
			||||||
 | 
					    Plus filesystems are often grumpy about using `:` in paths.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return Path('tmp/buildserver', get_container_name(appid, vercode), 'Vagrantfile')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def vagrant_exec(appid, vercode, command, as_root=False):
 | 
				
			||||||
 | 
					    """Execute a command in the Vagrant VM via ssh."""
 | 
				
			||||||
 | 
					    vagrantfile = get_vagrantfile_path(appid, vercode)
 | 
				
			||||||
 | 
					    to_stdin = shlex.join(command)
 | 
				
			||||||
 | 
					    p = subprocess.run(
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            'vagrant',
 | 
				
			||||||
 | 
					            'ssh',
 | 
				
			||||||
 | 
					            '-c',
 | 
				
			||||||
 | 
					            'sudo bash' if as_root else 'bash',
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        input=to_stdin,
 | 
				
			||||||
 | 
					        text=True,
 | 
				
			||||||
 | 
					        cwd=vagrantfile.parent,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if p.returncode != 0:
 | 
				
			||||||
 | 
					        raise subprocess.CalledProcessError(
 | 
				
			||||||
 | 
					            p.returncode, f"{to_stdin} | {' '.join(p.args)}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def vagrant_destroy(appid, vercode):
 | 
				
			||||||
 | 
					    import vagrant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    vagrantfile = get_vagrantfile_path(appid, vercode)
 | 
				
			||||||
 | 
					    if vagrantfile.is_file():
 | 
				
			||||||
 | 
					        logging.info(f"Destroying Vagrant buildserver VM ({appid}:{vercode})")
 | 
				
			||||||
 | 
					        v = vagrant.Vagrant(vagrantfile.parent)
 | 
				
			||||||
 | 
					        v.destroy()
 | 
				
			||||||
 | 
					    if vagrantfile.parent.exists():
 | 
				
			||||||
 | 
					        shutil.rmtree(vagrantfile.parent, ignore_errors=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_vagrant_bin_path():
 | 
				
			||||||
 | 
					    p = shutil.which("vagrant")
 | 
				
			||||||
 | 
					    if p is None:
 | 
				
			||||||
 | 
					        raise Exception(
 | 
				
			||||||
 | 
					            "'vagrant' not found, make sure it's installed and added to your path"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return p
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_rsync_bin_path():
 | 
				
			||||||
 | 
					    p = shutil.which("rsync")
 | 
				
			||||||
 | 
					    if p is None:
 | 
				
			||||||
 | 
					        raise Exception(
 | 
				
			||||||
 | 
					            "'rsync' not found, make sure it's installed and added to your path"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return p
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TmpVagrantSshConf:
 | 
				
			||||||
 | 
					    """Context manager for getting access to a ssh config of a vagrant VM in form of a temp file."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, vagrant_bin_path, vagrant_vm_dir):
 | 
				
			||||||
 | 
					        self.vagrant_bin_path = vagrant_bin_path
 | 
				
			||||||
 | 
					        self.vagrant_vm_dir = vagrant_vm_dir
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __enter__(self):
 | 
				
			||||||
 | 
					        self.ssh_config_tf = tempfile.NamedTemporaryFile('wt', encoding="utf8")
 | 
				
			||||||
 | 
					        self.ssh_config_tf.write(
 | 
				
			||||||
 | 
					            subprocess.check_output(
 | 
				
			||||||
 | 
					                [self.vagrant_bin_path, 'ssh-config'],
 | 
				
			||||||
 | 
					                cwd=self.vagrant_vm_dir,
 | 
				
			||||||
 | 
					            ).decode('utf8')
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.ssh_config_tf.flush()
 | 
				
			||||||
 | 
					        return self.ssh_config_tf.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __exit__(self, exc_type, exc_value, traceback):
 | 
				
			||||||
 | 
					        self.ssh_config_tf.close()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										85
									
								
								fdroidserver/destroy.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								fdroidserver/destroy.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,85 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# destroy.py - part of the FDroid server tools
 | 
				
			||||||
 | 
					# Copyright (C) 2024, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
 | 
					# Copyright (C) 2024, Michael Pöhn <michael@poehn.at>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software: you can redistribute it and/or modify
 | 
				
			||||||
 | 
					# it under the terms of the GNU Affero General Public License as published by
 | 
				
			||||||
 | 
					# the Free Software Foundation, either version 3 of the License, or
 | 
				
			||||||
 | 
					# (at your option) any later version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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 Affero General Public License for more details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU Affero General Public License
 | 
				
			||||||
 | 
					# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""Destroy any existing per-build container/VM structures.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					After this runs, there should be no trace of the given
 | 
				
			||||||
 | 
					ApplicationID:versionCode left in the container/VM system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Since this is an internal command, the strings are not localized.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import traceback
 | 
				
			||||||
 | 
					from argparse import ArgumentParser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import common
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# TODO should this track whether it actually removed something?
 | 
				
			||||||
 | 
					# What do `podman rm` and `vagrant destroy` do?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def podman_rm(appid, vercode):
 | 
				
			||||||
 | 
					    """Remove a Podman pod and all its containers."""
 | 
				
			||||||
 | 
					    pod_name = common.get_pod_name(appid, vercode)
 | 
				
			||||||
 | 
					    for p in common.get_podman_client().pods.list():
 | 
				
			||||||
 | 
					        if p.name == pod_name:
 | 
				
			||||||
 | 
					            logging.debug(f'Removing {pod_name}.')
 | 
				
			||||||
 | 
					            p.remove(force=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def destroy_wrapper(appid, vercode, virt_container_type):
 | 
				
			||||||
 | 
					    if virt_container_type == 'vagrant':
 | 
				
			||||||
 | 
					        common.vagrant_destroy(appid, vercode)
 | 
				
			||||||
 | 
					    elif virt_container_type == 'podman':
 | 
				
			||||||
 | 
					        podman_rm(appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    parser = ArgumentParser(
 | 
				
			||||||
 | 
					        description="Push files into the buildserver container/box."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    common.setup_global_opts(parser)
 | 
				
			||||||
 | 
					    common.setup_virt_container_type_opts(parser)
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "APPID:VERCODE",
 | 
				
			||||||
 | 
					        help="Application ID with Version Code in the form APPID:VERCODE",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    options = common.parse_args(parser)
 | 
				
			||||||
 | 
					    common.set_console_logging(options.verbose)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        appid, vercode = common.split_pkg_arg(options.__dict__['APPID:VERCODE'])
 | 
				
			||||||
 | 
					        destroy_wrapper(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            common.get_virt_container_type(options),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        if options.verbose:
 | 
				
			||||||
 | 
					            logging.error(traceback.format_exc())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logging.error(e)
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										74
									
								
								fdroidserver/exec.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								fdroidserver/exec.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,74 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# exec.py - part of the FDroid server tools
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Michael Pöhn <michael@poehn.at>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software: you can redistribute it and/or modify
 | 
				
			||||||
 | 
					# it under the terms of the GNU Affero General Public License as published by
 | 
				
			||||||
 | 
					# the Free Software Foundation, either version 3 of the License, or
 | 
				
			||||||
 | 
					# (at your option) any later version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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 Affero General Public License for more details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU Affero General Public License
 | 
				
			||||||
 | 
					# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""Run an fdroidserver subcommand inside of the VM for the build.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Since this is an internal command, the strings are not localized.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import traceback
 | 
				
			||||||
 | 
					from argparse import ArgumentParser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import common
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    parser = ArgumentParser(
 | 
				
			||||||
 | 
					        description="Run a subcommand in the buildserver container/box."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        '--as-root',
 | 
				
			||||||
 | 
					        default=False,
 | 
				
			||||||
 | 
					        action='store_true',
 | 
				
			||||||
 | 
					        help="run command inside of container/VM as root user",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    common.setup_global_opts(parser)
 | 
				
			||||||
 | 
					    common.setup_virt_container_type_opts(parser)
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "APPID:VERCODE",
 | 
				
			||||||
 | 
					        help="Application ID with Version Code in the form APPID:VERCODE",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "COMMAND", nargs="*", help="Command to run inside the container/box."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    options = common.parse_args(parser)
 | 
				
			||||||
 | 
					    common.set_console_logging(options.verbose)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        appid, vercode = common.split_pkg_arg(options.__dict__['APPID:VERCODE'])
 | 
				
			||||||
 | 
					        common.inside_exec(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            options.COMMAND,
 | 
				
			||||||
 | 
					            common.get_virt_container_type(options),
 | 
				
			||||||
 | 
					            options.as_root,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        if options.verbose:
 | 
				
			||||||
 | 
					            logging.error(traceback.format_exc())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logging.error(e)
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										132
									
								
								fdroidserver/pull.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								fdroidserver/pull.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,132 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# pull.py - part of the FDroid server tools
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Michael Pöhn <michael@poehn.at>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software: you can redistribute it and/or modify
 | 
				
			||||||
 | 
					# it under the terms of the GNU Affero General Public License as published by
 | 
				
			||||||
 | 
					# the Free Software Foundation, either version 3 of the License, or
 | 
				
			||||||
 | 
					# (at your option) any later version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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 Affero General Public License for more details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU Affero General Public License
 | 
				
			||||||
 | 
					# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""Pull a single file from the build setup for a given ApplicationID:versionCode.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This is a carefully constructed method to copy files from inside the
 | 
				
			||||||
 | 
					buildserver VM/container to the host filesystem for further processing
 | 
				
			||||||
 | 
					and publishing.  The source path is forced to the within the app's
 | 
				
			||||||
 | 
					build dir, and the destination is forced to the repo's unsigned/ dir.
 | 
				
			||||||
 | 
					This is not meant as a generic method for getting files, that is already
 | 
				
			||||||
 | 
					provided by each VM/container system (e.g. `podman cp`).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Since this is an internal command, the strings are not localized.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					import tarfile
 | 
				
			||||||
 | 
					import tempfile
 | 
				
			||||||
 | 
					import traceback
 | 
				
			||||||
 | 
					from argparse import ArgumentParser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import common, metadata
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def podman_pull(appid, vercode, path):
 | 
				
			||||||
 | 
					    """Implement `fdroid pull` for Podman (e.g. not `podman pull`)."""
 | 
				
			||||||
 | 
					    path_in_container = os.path.join(common.BUILD_HOME, path)
 | 
				
			||||||
 | 
					    container = common.get_podman_container(appid, vercode)
 | 
				
			||||||
 | 
					    stream, stat = container.get_archive(path_in_container)
 | 
				
			||||||
 | 
					    if not stat['linkTarget'].endswith(path) or stat['name'] != os.path.basename(path):
 | 
				
			||||||
 | 
					        logging.warning(f'{path} not found!')
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    with tempfile.NamedTemporaryFile(prefix=".fdroidserver_pull_", suffix=".tar") as tf:
 | 
				
			||||||
 | 
					        for i in stream:
 | 
				
			||||||
 | 
					            tf.write(i)
 | 
				
			||||||
 | 
					        tf.seek(0)
 | 
				
			||||||
 | 
					        with tarfile.TarFile(fileobj=tf, mode='r') as tar:
 | 
				
			||||||
 | 
					            tar.extract(stat['name'], 'unsigned', set_attrs=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def vagrant_pull(appid, vercode, path):
 | 
				
			||||||
 | 
					    """Pull the path from the Vagrant VM."""
 | 
				
			||||||
 | 
					    vagrantfile = common.get_vagrantfile_path(appid, vercode)
 | 
				
			||||||
 | 
					    path_in_vm = os.path.join(common.BUILD_HOME, path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    vagrantbin = common.get_vagrant_bin_path()
 | 
				
			||||||
 | 
					    rsyncbin = common.get_rsync_bin_path()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with common.TmpVagrantSshConf(vagrantbin, vagrantfile.parent) as ssh_config_file:
 | 
				
			||||||
 | 
					        cmd = [
 | 
				
			||||||
 | 
					            rsyncbin,
 | 
				
			||||||
 | 
					            '-av',
 | 
				
			||||||
 | 
					            '-e',
 | 
				
			||||||
 | 
					            f'ssh -F {ssh_config_file}',
 | 
				
			||||||
 | 
					            f'default:{path_in_vm}',
 | 
				
			||||||
 | 
					            './unsigned',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        subprocess.check_call(cmd)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def make_file_list(appid, vercode):
 | 
				
			||||||
 | 
					    app, build = metadata.get_single_build(appid, vercode)
 | 
				
			||||||
 | 
					    ext = common.get_output_extension(build)
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					        os.path.join('unsigned', common.get_release_filename(app, build, ext)),
 | 
				
			||||||
 | 
					        os.path.join(
 | 
				
			||||||
 | 
					            'unsigned', common.get_src_tarball_name(app.id, build.versionCode)
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def pull_wrapper(appid, vercode, virt_container_type):
 | 
				
			||||||
 | 
					    os.makedirs('unsigned', exist_ok=True)
 | 
				
			||||||
 | 
					    files = make_file_list(appid, vercode)
 | 
				
			||||||
 | 
					    for f in files:
 | 
				
			||||||
 | 
					        logging.info(f"""Pulling {f} from {appid}:{vercode}""")
 | 
				
			||||||
 | 
					        if virt_container_type == 'vagrant':
 | 
				
			||||||
 | 
					            vagrant_pull(appid, vercode, f)
 | 
				
			||||||
 | 
					        elif virt_container_type == 'podman':
 | 
				
			||||||
 | 
					            podman_pull(appid, vercode, f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    parser = ArgumentParser(
 | 
				
			||||||
 | 
					        description="Pull build products from the buildserver container/box."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    common.setup_global_opts(parser)
 | 
				
			||||||
 | 
					    common.setup_virt_container_type_opts(parser)
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "APPID:VERCODE",
 | 
				
			||||||
 | 
					        help="Application ID with Version Code in the form APPID:VERCODE",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    options = common.parse_args(parser)
 | 
				
			||||||
 | 
					    common.set_console_logging(options.verbose)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        appid, vercode = common.split_pkg_arg(options.__dict__['APPID:VERCODE'])
 | 
				
			||||||
 | 
					        pull_wrapper(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            common.get_virt_container_type(options),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        if options.verbose:
 | 
				
			||||||
 | 
					            logging.error(traceback.format_exc())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logging.error(e)
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										226
									
								
								fdroidserver/push.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								fdroidserver/push.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,226 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# push.py - part of the FDroid server tools
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Michael Pöhn <michael@poehn.at>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software: you can redistribute it and/or modify
 | 
				
			||||||
 | 
					# it under the terms of the GNU Affero General Public License as published by
 | 
				
			||||||
 | 
					# the Free Software Foundation, either version 3 of the License, or
 | 
				
			||||||
 | 
					# (at your option) any later version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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 Affero General Public License for more details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU Affero General Public License
 | 
				
			||||||
 | 
					# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""Push files into the build setup for a given ApplicationID:versionCode.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This is a carefully constructed method to copy files from the host
 | 
				
			||||||
 | 
					filesystem to inside the buildserver VM/container to run the build.  The
 | 
				
			||||||
 | 
					source paths are generated based on the build metadata, and the
 | 
				
			||||||
 | 
					destination is forced to build home.  This is not meant as a generic
 | 
				
			||||||
 | 
					method for copying files, that is already provided by each VM/container
 | 
				
			||||||
 | 
					system (e.g. `podman cp`).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Since this is an internal command, the strings are not localized.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import tarfile
 | 
				
			||||||
 | 
					import tempfile
 | 
				
			||||||
 | 
					import traceback
 | 
				
			||||||
 | 
					from argparse import ArgumentParser
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import common, metadata
 | 
				
			||||||
 | 
					from .exception import BuildException
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def podman_push(paths, appid, vercode, as_root=False):
 | 
				
			||||||
 | 
					    """Push relative paths into the podman container using the tar method.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This builds up a tar file from the supplied paths to send into
 | 
				
			||||||
 | 
					    container via put_archive(). This assumes it is running in the
 | 
				
			||||||
 | 
					    base of fdroiddata and it will push into common.BUILD_HOME in the
 | 
				
			||||||
 | 
					    container.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This is a version of podman.api.create_tar() that builds up via
 | 
				
			||||||
 | 
					    adding paths rather than giving a base dir and an exclude list.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _tar_perms_filter(tarinfo):
 | 
				
			||||||
 | 
					        """Force perms to something safe."""
 | 
				
			||||||
 | 
					        if '__pycache__' in tarinfo.name:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if as_root:
 | 
				
			||||||
 | 
					            tarinfo.uid = tarinfo.gid = 0
 | 
				
			||||||
 | 
					            tarinfo.uname = tarinfo.gname = 'root'
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            tarinfo.uid = tarinfo.gid = 1000
 | 
				
			||||||
 | 
					            tarinfo.uname = tarinfo.gname = common.BUILD_USER
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if tarinfo.isdir():
 | 
				
			||||||
 | 
					            tarinfo.mode = 0o0755
 | 
				
			||||||
 | 
					        elif tarinfo.isfile():
 | 
				
			||||||
 | 
					            if tarinfo.mode & 0o111:
 | 
				
			||||||
 | 
					                tarinfo.mode = 0o0755
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                tarinfo.mode = 0o0644
 | 
				
			||||||
 | 
					        elif not tarinfo.issym():  # symlinks shouldn't need perms set
 | 
				
			||||||
 | 
					            raise BuildException(f'{tarinfo.name} is not a file or directory!')
 | 
				
			||||||
 | 
					        return tarinfo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if isinstance(paths, Path):
 | 
				
			||||||
 | 
					        paths = [str(paths)]
 | 
				
			||||||
 | 
					    if isinstance(paths, str):
 | 
				
			||||||
 | 
					        paths = [paths]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    container = common.get_podman_container(appid, vercode)
 | 
				
			||||||
 | 
					    with tempfile.TemporaryFile() as tf:
 | 
				
			||||||
 | 
					        with tarfile.TarFile(fileobj=tf, mode='w') as tar:
 | 
				
			||||||
 | 
					            for f in paths:
 | 
				
			||||||
 | 
					                if Path(f).is_absolute():
 | 
				
			||||||
 | 
					                    raise BuildException(f'{f} must be relative to {Path.cwd()}')
 | 
				
			||||||
 | 
					                # throw ValueError on bad path
 | 
				
			||||||
 | 
					                f = (Path.cwd() / f).resolve().relative_to(Path.cwd())
 | 
				
			||||||
 | 
					                tar.add(f, filter=_tar_perms_filter)
 | 
				
			||||||
 | 
					        tf.seek(0)
 | 
				
			||||||
 | 
					        container.put_archive(common.BUILD_HOME, tf.read())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def vagrant_push(paths, appid, vercode):
 | 
				
			||||||
 | 
					    """Push files into a build specific vagrant VM."""
 | 
				
			||||||
 | 
					    vagrantbin = common.get_vagrant_bin_path()
 | 
				
			||||||
 | 
					    rsyncbin = common.get_rsync_bin_path()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    vagrantfile = common.get_vagrantfile_path(appid, vercode)
 | 
				
			||||||
 | 
					    with common.TmpVagrantSshConf(vagrantbin, vagrantfile.parent) as ssh_config_file:
 | 
				
			||||||
 | 
					        for path in paths:
 | 
				
			||||||
 | 
					            cmd = [
 | 
				
			||||||
 | 
					                rsyncbin,
 | 
				
			||||||
 | 
					                '-av',
 | 
				
			||||||
 | 
					                '--relative',
 | 
				
			||||||
 | 
					                '-e',
 | 
				
			||||||
 | 
					                f'ssh -F {ssh_config_file}',
 | 
				
			||||||
 | 
					                path,
 | 
				
			||||||
 | 
					                'default:{}'.format(common.BUILD_HOME),
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					            subprocess.check_call(
 | 
				
			||||||
 | 
					                cmd,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# TODO split out shared code into _podman.py, _vagrant.py, etc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def push_wrapper(paths, appid, vercode, virt_container_type):
 | 
				
			||||||
 | 
					    """Push standard set of files into VM/container."""
 | 
				
			||||||
 | 
					    if virt_container_type == 'vagrant':
 | 
				
			||||||
 | 
					        vagrant_push(paths, appid, vercode)
 | 
				
			||||||
 | 
					    elif virt_container_type == 'podman':
 | 
				
			||||||
 | 
					        podman_push(paths, appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def make_file_list(appid, vercode):
 | 
				
			||||||
 | 
					    """Assemble list of files/folders that go into this specific build."""
 | 
				
			||||||
 | 
					    files = [f'build/{appid}', f'metadata/{appid}.yml']
 | 
				
			||||||
 | 
					    app_dir = f'metadata/{appid}'
 | 
				
			||||||
 | 
					    if Path(app_dir).exists():
 | 
				
			||||||
 | 
					        files.append(app_dir)
 | 
				
			||||||
 | 
					    app, build = metadata.get_single_build(appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for lib in build.srclibs:
 | 
				
			||||||
 | 
					        srclib = common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False)
 | 
				
			||||||
 | 
					        if srclib:
 | 
				
			||||||
 | 
					            # srclib metadata file
 | 
				
			||||||
 | 
					            files.append(f"srclibs/{srclib[0]}.yml")
 | 
				
			||||||
 | 
					            # srclib sourcecode
 | 
				
			||||||
 | 
					            files.append(srclib[2])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    extlib_paths = [f"build/extlib/{lib}" for lib in build.extlibs]
 | 
				
			||||||
 | 
					    missing_extlibs = [p for p in extlib_paths if not os.path.exists(p)]
 | 
				
			||||||
 | 
					    if any(missing_extlibs):
 | 
				
			||||||
 | 
					        raise Exception(
 | 
				
			||||||
 | 
					            "error: requested missing extlibs: {}".format(" ".join(missing_extlibs))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    files.extend(extlib_paths)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_build_dirs(appid, vercode, virt_container_type):
 | 
				
			||||||
 | 
					    """Create directories required for running builds."""
 | 
				
			||||||
 | 
					    dirs = ('build', 'build/extlib', 'build/srclib', 'metadata', 'fdroidserver')
 | 
				
			||||||
 | 
					    for dir_name in dirs:
 | 
				
			||||||
 | 
					        cmd = ['mkdir', '--parents', f'{common.BUILD_HOME}/{dir_name}']
 | 
				
			||||||
 | 
					        common.inside_exec(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            cmd,
 | 
				
			||||||
 | 
					            virt_container_type,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def full_push_sequence(appid, vercode, virt_container_type):
 | 
				
			||||||
 | 
					    """Push all files into vm required for specified build."""
 | 
				
			||||||
 | 
					    create_build_dirs(appid, vercode, virt_container_type)
 | 
				
			||||||
 | 
					    push_wrapper(make_file_list(appid, vercode), appid, vercode, virt_container_type)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # fdroidserver is pushed in every build
 | 
				
			||||||
 | 
					    cwd = Path('.').absolute()
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        os.chdir(Path(__file__).parent.parent.parent)
 | 
				
			||||||
 | 
					        push_wrapper(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                'fdroidserver/fdroidserver',
 | 
				
			||||||
 | 
					                'fdroidserver/fdroid',
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            virt_container_type,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    finally:
 | 
				
			||||||
 | 
					        os.chdir(cwd)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    """CLI main method for this subcommand."""
 | 
				
			||||||
 | 
					    parser = ArgumentParser(
 | 
				
			||||||
 | 
					        description="Push full build setup into the buildserver container/box."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    common.setup_global_opts(parser)
 | 
				
			||||||
 | 
					    common.setup_virt_container_type_opts(parser)
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "APPID:VERCODE",
 | 
				
			||||||
 | 
					        help="Application ID with Version Code in the form APPID:VERCODE",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    options = common.parse_args(parser)
 | 
				
			||||||
 | 
					    common.set_console_logging(options.verbose)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        appid, vercode = common.split_pkg_arg(options.__dict__['APPID:VERCODE'])
 | 
				
			||||||
 | 
					        full_push_sequence(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            common.get_virt_container_type(options),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        if options.verbose:
 | 
				
			||||||
 | 
					            logging.error(traceback.format_exc())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logging.error(e)
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										183
									
								
								fdroidserver/up.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								fdroidserver/up.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,183 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# up.py - part of the FDroid server tools
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Hans-Christoph Steiner <hans@eds.org>
 | 
				
			||||||
 | 
					# Copyright (C) 2024-2025, Michael Pöhn <michael@poehn.at>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software: you can redistribute it and/or modify
 | 
				
			||||||
 | 
					# it under the terms of the GNU Affero General Public License as published by
 | 
				
			||||||
 | 
					# the Free Software Foundation, either version 3 of the License, or
 | 
				
			||||||
 | 
					# (at your option) any later version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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 Affero General Public License for more details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU Affero General Public License
 | 
				
			||||||
 | 
					# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""Create dedicated VM/container to run single build, destroying any existing ones.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The ApplicationID:versionCode argument from the command line should be
 | 
				
			||||||
 | 
					used as the unique identifier.  This is necessary so that the other
 | 
				
			||||||
 | 
					related processes (push, pull, destroy) can find the dedicated
 | 
				
			||||||
 | 
					container/VM without there being any other database or file lookup.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Since this is an internal command, the strings are not localized.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import textwrap
 | 
				
			||||||
 | 
					import traceback
 | 
				
			||||||
 | 
					from argparse import ArgumentParser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import common
 | 
				
			||||||
 | 
					from .exception import BuildException
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_podman(appid, vercode):
 | 
				
			||||||
 | 
					    """Create a Podman container env isolated for a single app build.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This creates a Podman "pod", which is like an isolated box to
 | 
				
			||||||
 | 
					    create containers in.  Then it creates a container in that pod to
 | 
				
			||||||
 | 
					    run the actual processes.  Using the "pod" seems to be a
 | 
				
			||||||
 | 
					    requirement of Podman.  It also further isolates each app build,
 | 
				
			||||||
 | 
					    so seems fine to use.  It is confusing because these containers
 | 
				
			||||||
 | 
					    won't show up by default when listing containers using defaults.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    The container is set up with an interactive bash process to keep
 | 
				
			||||||
 | 
					    the container running.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    container_name = common.get_container_name(appid, vercode)
 | 
				
			||||||
 | 
					    pod_name = common.get_pod_name(appid, vercode)
 | 
				
			||||||
 | 
					    client = common.get_podman_client()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logging.debug(f'Pulling {common.PODMAN_BUILDSERVER_IMAGE}...')
 | 
				
			||||||
 | 
					    image = client.images.pull(common.PODMAN_BUILDSERVER_IMAGE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for c in client.containers.list():
 | 
				
			||||||
 | 
					        if c.name == container_name:
 | 
				
			||||||
 | 
					            logging.warning(f'Container {container_name} exists, removing!')
 | 
				
			||||||
 | 
					            c.remove(force=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for p in client.pods.list():
 | 
				
			||||||
 | 
					        if p.name == pod_name:
 | 
				
			||||||
 | 
					            logging.warning(f'Pod {pod_name} exists, removing!')
 | 
				
			||||||
 | 
					            p.remove(force=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pod = client.pods.create(pod_name)
 | 
				
			||||||
 | 
					    container = client.containers.create(
 | 
				
			||||||
 | 
					        image,
 | 
				
			||||||
 | 
					        command=['/bin/bash', '-e', '-i', '-l'],
 | 
				
			||||||
 | 
					        pod=pod,
 | 
				
			||||||
 | 
					        name=container_name,
 | 
				
			||||||
 | 
					        detach=True,
 | 
				
			||||||
 | 
					        remove=True,
 | 
				
			||||||
 | 
					        stdin_open=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    pod.start()
 | 
				
			||||||
 | 
					    pod.reload()
 | 
				
			||||||
 | 
					    if container.status != 'created':
 | 
				
			||||||
 | 
					        raise BuildException(
 | 
				
			||||||
 | 
					            f'Container {container_name} failed to start ({container.status})!'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_vagrant(appid, vercode, cpus, memory):
 | 
				
			||||||
 | 
					    import vagrant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if cpus is None or not isinstance(cpus, int) or not cpus > 0:
 | 
				
			||||||
 | 
					        raise BuildException(
 | 
				
			||||||
 | 
					            f"vagrant cpu setting required, '{cpus}' not a valid value!"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    if memory is None or not isinstance(memory, int) or not memory > 0:
 | 
				
			||||||
 | 
					        raise BuildException(
 | 
				
			||||||
 | 
					            f"vagrant memory setting required, '{memory}' not a valid value!"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    vagrantfile = common.get_vagrantfile_path(appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # cleanup potentially still existsing vagrant VMs/dirs
 | 
				
			||||||
 | 
					    common.vagrant_destroy(appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # start new dedicated buildserver vagrant vm from scratch
 | 
				
			||||||
 | 
					    vagrantfile.parent.mkdir(exist_ok=True, parents=True)
 | 
				
			||||||
 | 
					    vagrantfile.write_text(
 | 
				
			||||||
 | 
					        textwrap.dedent(
 | 
				
			||||||
 | 
					            f"""# generated file, do not change.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Vagrant.configure("2") do |config|
 | 
				
			||||||
 | 
					                  config.vm.box = "buildserver"
 | 
				
			||||||
 | 
					                  config.vm.synced_folder ".", "/vagrant", disabled: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  config.vm.provider :libvirt do |libvirt|
 | 
				
			||||||
 | 
					                    libvirt.cpus = {cpus}
 | 
				
			||||||
 | 
					                    libvirt.memory = {memory}
 | 
				
			||||||
 | 
					                  end
 | 
				
			||||||
 | 
					                end
 | 
				
			||||||
 | 
					            """
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    v = vagrant.Vagrant(vagrantfile.parent)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not any((b for b in v.box_list() if b.name == 'buildserver')):
 | 
				
			||||||
 | 
					        raise BuildException("'buildserver' box not added to vagrant")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    v.up()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def up_wrapper(appid, vercode, virt_container_type, cpus=None, memory=None):
 | 
				
			||||||
 | 
					    if virt_container_type == 'vagrant':
 | 
				
			||||||
 | 
					        run_vagrant(appid, vercode, cpus, memory)
 | 
				
			||||||
 | 
					    elif virt_container_type == 'podman':
 | 
				
			||||||
 | 
					        run_podman(appid, vercode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    parser = ArgumentParser(
 | 
				
			||||||
 | 
					        description="Create dedicated VM/container to run single build."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    common.setup_global_opts(parser)
 | 
				
			||||||
 | 
					    common.setup_virt_container_type_opts(parser)
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "APPID:VERCODE",
 | 
				
			||||||
 | 
					        help="Application ID with Version Code in the form APPID:VERCODE",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "--cpus",
 | 
				
			||||||
 | 
					        default=None,
 | 
				
			||||||
 | 
					        type=int,
 | 
				
			||||||
 | 
					        help="How many CPUs the Vagrant VM should be allocated.",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    parser.add_argument(
 | 
				
			||||||
 | 
					        "--memory",
 | 
				
			||||||
 | 
					        default=None,
 | 
				
			||||||
 | 
					        type=int,
 | 
				
			||||||
 | 
					        help="How many MB of RAM the Vagrant VM should be allocated.",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    options = common.parse_args(parser)
 | 
				
			||||||
 | 
					    common.set_console_logging(options.verbose)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        appid, vercode = common.split_pkg_arg(options.__dict__['APPID:VERCODE'])
 | 
				
			||||||
 | 
					        up_wrapper(
 | 
				
			||||||
 | 
					            appid,
 | 
				
			||||||
 | 
					            vercode,
 | 
				
			||||||
 | 
					            common.get_virt_container_type(options),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        if options.verbose:
 | 
				
			||||||
 | 
					            logging.error(traceback.format_exc())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logging.error(e)
 | 
				
			||||||
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										1
									
								
								setup.py
									
										
									
									
									
								
							
							
						
						
									
										1
									
								
								setup.py
									
										
									
									
									
								
							| 
						 | 
					@ -122,6 +122,7 @@ setup(
 | 
				
			||||||
            'pycountry',
 | 
					            'pycountry',
 | 
				
			||||||
            'python-magic',
 | 
					            'python-magic',
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
 | 
					        'podman': ['podman'],
 | 
				
			||||||
        'test': ['pyjks', 'html5print', 'testcontainers[minio]'],
 | 
					        'test': ['pyjks', 'html5print', 'testcontainers[minio]'],
 | 
				
			||||||
        'docs': [
 | 
					        'docs': [
 | 
				
			||||||
            'sphinx',
 | 
					            'sphinx',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -29,6 +29,11 @@ class VerboseFalseOptions:
 | 
				
			||||||
    verbose = False
 | 
					    verbose = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					APPID = 'com.example'
 | 
				
			||||||
 | 
					VERCODE = 123
 | 
				
			||||||
 | 
					APPID_VERCODE = f'{APPID}:{VERCODE}'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TmpCwd:
 | 
					class TmpCwd:
 | 
				
			||||||
    """Context-manager for temporarily changing the current working directory."""
 | 
					    """Context-manager for temporarily changing the current working directory."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3651,3 +3651,57 @@ class GetHeadCommitIdTest(unittest.TestCase):
 | 
				
			||||||
        git_repo.git.add(all=True)
 | 
					        git_repo.git.add(all=True)
 | 
				
			||||||
        git_repo.index.commit("add code")
 | 
					        git_repo.index.commit("add code")
 | 
				
			||||||
        self.assertIsNotNone(fdroidserver.common.get_head_commit_id(git_repo))
 | 
					        self.assertIsNotNone(fdroidserver.common.get_head_commit_id(git_repo))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class VirtContainerTypeTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    """Test the logic for choosing which VM/container system to use."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self._td = mkdtemp()
 | 
				
			||||||
 | 
					        self.testdir = self._td.name
 | 
				
			||||||
 | 
					        os.chdir(self.testdir)
 | 
				
			||||||
 | 
					        fdroidserver.common.config = None
 | 
				
			||||||
 | 
					        fdroidserver.common.options = None
 | 
				
			||||||
 | 
					        # self.options represents the output of argparse.parser()
 | 
				
			||||||
 | 
					        self.options = mock.Mock()
 | 
				
			||||||
 | 
					        self.options.virt_container_type = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        os.chdir(basedir)
 | 
				
			||||||
 | 
					        self._td.cleanup()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_virt_container_type_unset(self):
 | 
				
			||||||
 | 
					        with self.assertLogs(level=logging.ERROR) as logs:
 | 
				
			||||||
 | 
					            with self.assertRaises(SystemExit):
 | 
				
			||||||
 | 
					                fdroidserver.common.get_virt_container_type(self.options)
 | 
				
			||||||
 | 
					            self.assertIn('virt_container_type', logs.output[0])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_virt_container_type_config(self):
 | 
				
			||||||
 | 
					        testvalue = 'podman'
 | 
				
			||||||
 | 
					        Path('config.yml').write_text(f'virt_container_type: {testvalue}\n')
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            testvalue, fdroidserver.common.get_virt_container_type(self.options)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_virt_container_type_options(self):
 | 
				
			||||||
 | 
					        testvalue = 'podman'
 | 
				
			||||||
 | 
					        self.options.virt_container_type = testvalue
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            testvalue, fdroidserver.common.get_virt_container_type(self.options)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_virt_container_type_options_override_config(self):
 | 
				
			||||||
 | 
					        testvalue = 'podman'
 | 
				
			||||||
 | 
					        self.options.virt_container_type = testvalue
 | 
				
			||||||
 | 
					        Path('config.yml').write_text('virt_container_type: vagrant\n')
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            testvalue, fdroidserver.common.get_virt_container_type(self.options)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_virt_container_type_config_bad_value(self):
 | 
				
			||||||
 | 
					        testvalue = 'doesnotexist'
 | 
				
			||||||
 | 
					        Path('config.yml').write_text(f'virt_container_type: {testvalue}\n')
 | 
				
			||||||
 | 
					        with self.assertLogs(level=logging.ERROR) as logs:
 | 
				
			||||||
 | 
					            with self.assertRaises(SystemExit):
 | 
				
			||||||
 | 
					                fdroidserver.common.get_virt_container_type(self.options)
 | 
				
			||||||
 | 
					            self.assertIn(testvalue, logs.output[0])
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										63
									
								
								tests/test_exec.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										63
									
								
								tests/test_exec.py
									
										
									
									
									
										Executable file
									
								
							| 
						 | 
					@ -0,0 +1,63 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from unittest import mock, skipUnless
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from fdroidserver import common, exception, exec
 | 
				
			||||||
 | 
					from .shared_test_code import mkdtemp, APPID, VERCODE, APPID_VERCODE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ExecTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self._td = mkdtemp()
 | 
				
			||||||
 | 
					        self.testdir = self._td.name
 | 
				
			||||||
 | 
					        os.chdir(self.testdir)
 | 
				
			||||||
 | 
					        common.config = dict()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        self._td.cleanup()
 | 
				
			||||||
 | 
					        common.config = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Exec_main(ExecTest):
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid exec', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.common.get_default_cachedir')
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.common.podman_exec')
 | 
				
			||||||
 | 
					    def test_podman(self, podman_exec, get_default_cachedir):
 | 
				
			||||||
 | 
					        get_default_cachedir.return_value = self.testdir
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'podman'
 | 
				
			||||||
 | 
					        exec.main()
 | 
				
			||||||
 | 
					        podman_exec.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipUnless(os.path.isdir('/run/podman'), 'Requires Podman to run.')
 | 
				
			||||||
 | 
					class Exec_podman_exec(ExecTest):
 | 
				
			||||||
 | 
					    def _only_run_if_container_exists(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            common.get_podman_container(APPID, VERCODE)
 | 
				
			||||||
 | 
					        except exception.BuildException as e:
 | 
				
			||||||
 | 
					            # To run these tests, first do: `./fdroid up com.example:123`
 | 
				
			||||||
 | 
					            self.skipTest(f'Requires Podman container {APPID_VERCODE} to run: {e}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_no_existing_container(self):
 | 
				
			||||||
 | 
					        appid = 'should.never.exist'
 | 
				
			||||||
 | 
					        f = Path(f'metadata/{appid}.yml')
 | 
				
			||||||
 | 
					        f.parent.mkdir()
 | 
				
			||||||
 | 
					        f.write_text(f.name)
 | 
				
			||||||
 | 
					        with self.assertRaises(subprocess.CalledProcessError) as e:
 | 
				
			||||||
 | 
					            common.podman_exec(appid, 9999999999, ['ls'])
 | 
				
			||||||
 | 
					            self.assertEqual(e.exception.returncode, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_clean_run(self):
 | 
				
			||||||
 | 
					        self._only_run_if_container_exists()
 | 
				
			||||||
 | 
					        common.podman_exec(APPID, VERCODE, ['ls'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_error_run(self):
 | 
				
			||||||
 | 
					        self._only_run_if_container_exists()
 | 
				
			||||||
 | 
					        with self.assertRaises(subprocess.CalledProcessError) as e:
 | 
				
			||||||
 | 
					            common.podman_exec(APPID, VERCODE, ['/bin/false'])
 | 
				
			||||||
 | 
					            self.assertEqual(e.exception.returncode, 1)
 | 
				
			||||||
							
								
								
									
										122
									
								
								tests/test_pull.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										122
									
								
								tests/test_pull.py
									
										
									
									
									
										Executable file
									
								
							| 
						 | 
					@ -0,0 +1,122 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import importlib
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from unittest import mock, skipIf
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from fdroidserver import common, exception, pull
 | 
				
			||||||
 | 
					from .shared_test_code import mkdtemp, APPID, VERCODE, APPID_VERCODE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PullTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    basedir = Path(__file__).resolve().parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self._td = mkdtemp()
 | 
				
			||||||
 | 
					        self.testdir = self._td.name
 | 
				
			||||||
 | 
					        os.chdir(self.testdir)
 | 
				
			||||||
 | 
					        common.config = dict()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        self._td.cleanup()
 | 
				
			||||||
 | 
					        common.config = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Pull_main(PullTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        metadatapath = Path(common.get_metadatapath(APPID))
 | 
				
			||||||
 | 
					        metadatapath.parent.mkdir()
 | 
				
			||||||
 | 
					        metadatapath.write_text(f'Name: Test\nBuilds:\n - versionCode: {VERCODE}\n')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid pull', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.pull.podman_pull')
 | 
				
			||||||
 | 
					    def test_podman(self, podman_pull):
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'podman'
 | 
				
			||||||
 | 
					        common.options = mock.Mock()
 | 
				
			||||||
 | 
					        pull.main()
 | 
				
			||||||
 | 
					        podman_pull.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid pull', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.pull.vagrant_pull')
 | 
				
			||||||
 | 
					    def test_vagrant(self, vagrant_pull):
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'vagrant'
 | 
				
			||||||
 | 
					        pull.main()
 | 
				
			||||||
 | 
					        vagrant_pull.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipIf(importlib.util.find_spec("podman") is None, 'Requires podman-py to run.')
 | 
				
			||||||
 | 
					class Pull_podman_pull(PullTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            common.get_podman_container(APPID, VERCODE)
 | 
				
			||||||
 | 
					        except exception.BuildException as e:
 | 
				
			||||||
 | 
					            self.skipTest(f'Requires Podman container {APPID_VERCODE} to run: {e}')
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_no_existing(self):
 | 
				
			||||||
 | 
					        appid = 'should.never.exist'
 | 
				
			||||||
 | 
					        with self.assertRaises(exception.BuildException) as e:
 | 
				
			||||||
 | 
					            pull.podman_pull(appid, 9999, 'unsigned/foo.apk')
 | 
				
			||||||
 | 
					        self.assertIn(appid, e.exception.value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_existing(self):
 | 
				
			||||||
 | 
					        """Check files get deposited in unsigned/."""
 | 
				
			||||||
 | 
					        filename = 'buildserverid'
 | 
				
			||||||
 | 
					        f = Path('unsigned') / filename
 | 
				
			||||||
 | 
					        self.assertFalse(f.exists())
 | 
				
			||||||
 | 
					        pull.podman_pull(APPID, VERCODE, filename)
 | 
				
			||||||
 | 
					        self.assertTrue(f.exists())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Pull_make_file_list(PullTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        self.metadatapath = Path(common.get_metadatapath(APPID))
 | 
				
			||||||
 | 
					        self.metadatapath.parent.mkdir()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_implied(self):
 | 
				
			||||||
 | 
					        self.metadatapath.write_text(f"""Builds: [versionCode: {VERCODE}]""")
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}.apk',
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}_src.tar.gz',
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            pull.make_file_list(APPID, VERCODE),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_gradle(self):
 | 
				
			||||||
 | 
					        self.metadatapath.write_text(
 | 
				
			||||||
 | 
					            f"""Builds:
 | 
				
			||||||
 | 
					                      - versionCode: {VERCODE}
 | 
				
			||||||
 | 
					                        gradle: fdroid
 | 
				
			||||||
 | 
					            """
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}.apk',
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}_src.tar.gz',
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            pull.make_file_list(APPID, VERCODE),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_raw(self):
 | 
				
			||||||
 | 
					        ext = 'foo'
 | 
				
			||||||
 | 
					        Path(common.get_metadatapath(APPID)).write_text(
 | 
				
			||||||
 | 
					            f"""Builds:
 | 
				
			||||||
 | 
					              - versionCode: {VERCODE}
 | 
				
			||||||
 | 
					                versionName: 1.0
 | 
				
			||||||
 | 
					                commit: cafebabe123
 | 
				
			||||||
 | 
					                output: path/to/output.{ext}
 | 
				
			||||||
 | 
					            """
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}.{ext}',
 | 
				
			||||||
 | 
					                f'unsigned/{APPID}_{VERCODE}_src.tar.gz',
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            pull.make_file_list(APPID, VERCODE),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
							
								
								
									
										87
									
								
								tests/test_push.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										87
									
								
								tests/test_push.py
									
										
									
									
									
										Executable file
									
								
							| 
						 | 
					@ -0,0 +1,87 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import importlib
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from unittest import mock, skipIf
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from fdroidserver import common, exception, push
 | 
				
			||||||
 | 
					from .shared_test_code import mkdtemp, APPID, VERCODE, APPID_VERCODE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PushTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    basedir = Path(__file__).resolve().parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self._td = mkdtemp()
 | 
				
			||||||
 | 
					        self.testdir = self._td.name
 | 
				
			||||||
 | 
					        os.chdir(self.testdir)
 | 
				
			||||||
 | 
					        common.config = dict()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        self._td.cleanup()
 | 
				
			||||||
 | 
					        common.config = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Push_main(PushTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        metadatapath = Path(common.get_metadatapath(APPID))
 | 
				
			||||||
 | 
					        metadatapath.parent.mkdir()
 | 
				
			||||||
 | 
					        metadatapath.write_text(f'Name: Test\nBuilds:\n - versionCode: {VERCODE}\n')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid push', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.push.create_build_dirs')
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.push.podman_push')
 | 
				
			||||||
 | 
					    def test_podman(self, podman_push, create_build_dirs):
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'podman'
 | 
				
			||||||
 | 
					        push.main()
 | 
				
			||||||
 | 
					        create_build_dirs.assert_called_once()
 | 
				
			||||||
 | 
					        podman_push.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid push', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.push.create_build_dirs')
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.push.vagrant_push')
 | 
				
			||||||
 | 
					    def test_vagrant(self, vagrant_push, create_build_dirs):
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'vagrant'
 | 
				
			||||||
 | 
					        push.main()
 | 
				
			||||||
 | 
					        create_build_dirs.assert_called_once()
 | 
				
			||||||
 | 
					        vagrant_push.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipIf(importlib.util.find_spec("podman") is None, 'Requires podman-py to run.')
 | 
				
			||||||
 | 
					class Push_podman_push(PushTest):
 | 
				
			||||||
 | 
					    def _only_run_if_container_exists(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            common.get_podman_container(APPID, VERCODE)
 | 
				
			||||||
 | 
					        except exception.BuildException as e:
 | 
				
			||||||
 | 
					            # To run these tests, first do: `./fdroid up com.example:123`
 | 
				
			||||||
 | 
					            self.skipTest(f'Requires Podman container {APPID_VERCODE} to run: {e}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_no_existing_container(self):
 | 
				
			||||||
 | 
					        appid = 'should.never.exist'
 | 
				
			||||||
 | 
					        f = Path(f'metadata/{appid}.yml')
 | 
				
			||||||
 | 
					        f.parent.mkdir()
 | 
				
			||||||
 | 
					        f.write_text(f.name)
 | 
				
			||||||
 | 
					        with self.assertRaises(exception.BuildException):
 | 
				
			||||||
 | 
					            push.podman_push(f, appid, 9999)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_bad_absolute_path(self):
 | 
				
			||||||
 | 
					        self._only_run_if_container_exists()
 | 
				
			||||||
 | 
					        with self.assertRaises(exception.BuildException):
 | 
				
			||||||
 | 
					            push.podman_push('/etc/passwd', APPID, VERCODE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_bad_relative_path(self):
 | 
				
			||||||
 | 
					        self._only_run_if_container_exists()
 | 
				
			||||||
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
 | 
					            push.podman_push('../../etc/passwd', APPID, VERCODE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_existing(self):
 | 
				
			||||||
 | 
					        self._only_run_if_container_exists()
 | 
				
			||||||
 | 
					        f = Path(f'metadata/{APPID}.yml')
 | 
				
			||||||
 | 
					        f.parent.mkdir()
 | 
				
			||||||
 | 
					        f.write_text(f.name)
 | 
				
			||||||
 | 
					        push.podman_push(f, APPID, VERCODE)
 | 
				
			||||||
 | 
					        common.inside_exec(APPID, VERCODE, ['test', '-e', str(f)], 'podman')
 | 
				
			||||||
							
								
								
									
										144
									
								
								tests/test_up.py
									
										
									
									
									
										Executable file
									
								
							
							
						
						
									
										144
									
								
								tests/test_up.py
									
										
									
									
									
										Executable file
									
								
							| 
						 | 
					@ -0,0 +1,144 @@
 | 
				
			||||||
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import importlib
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import shutil
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from unittest import mock, skipIf, skipUnless
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from fdroidserver import common, exception, up
 | 
				
			||||||
 | 
					from .shared_test_code import mkdtemp, APPID, VERCODE, APPID_VERCODE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UpTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    basedir = Path(__file__).resolve().parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self._td = mkdtemp()
 | 
				
			||||||
 | 
					        self.testdir = self._td.name
 | 
				
			||||||
 | 
					        os.chdir(self.testdir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        self._td.cleanup()
 | 
				
			||||||
 | 
					        common.config = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Up_main(UpTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        common.config = dict()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @skipIf(
 | 
				
			||||||
 | 
					        importlib.util.find_spec("podman") is None or not shutil.which('podman'),
 | 
				
			||||||
 | 
					        'Requires podman and podman-py to run.',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid up', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.common.get_default_cachedir')
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.up.run_podman')
 | 
				
			||||||
 | 
					    def test_podman(self, run_podman, get_default_cachedir):
 | 
				
			||||||
 | 
					        get_default_cachedir.return_value = self.testdir
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'podman'
 | 
				
			||||||
 | 
					        up.main()
 | 
				
			||||||
 | 
					        run_podman.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('sys.argv', ['fdroid up', APPID_VERCODE])
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.common.get_default_cachedir')
 | 
				
			||||||
 | 
					    @mock.patch('fdroidserver.up.run_vagrant')
 | 
				
			||||||
 | 
					    def test_vagrant(self, run_vagrant, get_default_cachedir):
 | 
				
			||||||
 | 
					        get_default_cachedir.return_value = self.testdir
 | 
				
			||||||
 | 
					        common.config['virt_container_type'] = 'vagrant'
 | 
				
			||||||
 | 
					        up.main()
 | 
				
			||||||
 | 
					        run_vagrant.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipIf(
 | 
				
			||||||
 | 
					    importlib.util.find_spec("podman") is None or not shutil.which('podman'),
 | 
				
			||||||
 | 
					    'Requires podman and podman-py to run.',
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					class Up_run_podman(UpTest):
 | 
				
			||||||
 | 
					    @skipUnless(
 | 
				
			||||||
 | 
					        os.path.exists(f'/run/user/{os.getuid()}/podman/podman.sock'),
 | 
				
			||||||
 | 
					        'Requires systemd podman.socket to run.',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def test_up_with_systemd_socket(self):
 | 
				
			||||||
 | 
					        common.get_podman_client()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @skipIf(
 | 
				
			||||||
 | 
					        os.path.exists(f'/run/user/{os.getuid()}/podman/podman.sock'),
 | 
				
			||||||
 | 
					        'Requires the systemd podman.socket is not present.',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def test_up_with_podman_system_service_start(self):
 | 
				
			||||||
 | 
					        common.get_podman_client()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_recreate_existing(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            common.get_podman_container(APPID, VERCODE)
 | 
				
			||||||
 | 
					        except exception.BuildException as e:
 | 
				
			||||||
 | 
					            # To run these tests, first do: `./fdroid up com.example:123`
 | 
				
			||||||
 | 
					            self.skipTest(f'Requires Podman container {APPID_VERCODE} to run: {e}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        short_id = common.get_podman_container(APPID, VERCODE).short_id
 | 
				
			||||||
 | 
					        up.run_podman(APPID, VERCODE)
 | 
				
			||||||
 | 
					        self.assertNotEqual(
 | 
				
			||||||
 | 
					            short_id,
 | 
				
			||||||
 | 
					            common.get_podman_container(APPID, VERCODE).short_id,
 | 
				
			||||||
 | 
					            "This should never reuse an existing container.",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipIf(importlib.util.find_spec("podman") is None, 'Requires podman-py to run.')
 | 
				
			||||||
 | 
					class Up_run_fake_podman(UpTest):
 | 
				
			||||||
 | 
					    @skipIf(
 | 
				
			||||||
 | 
					        os.path.exists(f'/run/user/{os.getuid()}/podman/podman.sock'),
 | 
				
			||||||
 | 
					        'Requires the systemd podman.socket is not present.',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @mock.patch.dict(os.environ, clear=True)
 | 
				
			||||||
 | 
					    @mock.patch("podman.PodmanClient.ping")
 | 
				
			||||||
 | 
					    def test_up_with_podman_system_service_start(self, mock_client_ping):
 | 
				
			||||||
 | 
					        """Test that the system service gets started if no socket is present."""
 | 
				
			||||||
 | 
					        os.environ['PATH'] = os.path.join(self.testdir, 'bin')
 | 
				
			||||||
 | 
					        os.mkdir('bin')
 | 
				
			||||||
 | 
					        podman = Path('bin/podman')
 | 
				
			||||||
 | 
					        podman.write_text('#!/bin/sh\nprintf "$1 $2" > args\n')
 | 
				
			||||||
 | 
					        os.chmod(podman, 0o700)
 | 
				
			||||||
 | 
					        common.get_podman_client()
 | 
				
			||||||
 | 
					        self.assertEqual('system service', Path('args').read_text())
 | 
				
			||||||
 | 
					        mock_client_ping.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@skipIf(importlib.util.find_spec("vagrant") is None, 'Requires python-vagrant to run.')
 | 
				
			||||||
 | 
					class Up_run_vagrant(UpTest):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        b = mock.Mock()
 | 
				
			||||||
 | 
					        b.name = 'buildserver'
 | 
				
			||||||
 | 
					        self.box_list_return = [b]
 | 
				
			||||||
 | 
					        name = common.get_container_name(APPID, VERCODE)
 | 
				
			||||||
 | 
					        self.vagrantdir = Path('tmp/buildserver') / name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('vagrant.Vagrant.up')
 | 
				
			||||||
 | 
					    @mock.patch('vagrant.Vagrant.box_list')
 | 
				
			||||||
 | 
					    def test_no_existing(self, box_list, vagrant_up):
 | 
				
			||||||
 | 
					        box_list.return_value = self.box_list_return
 | 
				
			||||||
 | 
					        up.run_vagrant(APPID, VERCODE, 1, 1)
 | 
				
			||||||
 | 
					        vagrant_up.assert_called_once()
 | 
				
			||||||
 | 
					        self.assertTrue((Path(self.testdir) / self.vagrantdir).exists())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @mock.patch('vagrant.Vagrant.up')
 | 
				
			||||||
 | 
					    @mock.patch('vagrant.Vagrant.destroy')
 | 
				
			||||||
 | 
					    @mock.patch('vagrant.Vagrant.box_list')
 | 
				
			||||||
 | 
					    def test_existing(self, box_list, vagrant_destroy, vagrant_up):
 | 
				
			||||||
 | 
					        "This should never reuse an existing VM."
 | 
				
			||||||
 | 
					        box_list.return_value = self.box_list_return
 | 
				
			||||||
 | 
					        up.run_vagrant(APPID, VERCODE, 1, 1)
 | 
				
			||||||
 | 
					        vagrantfile = self.vagrantdir / 'Vagrantfile'
 | 
				
			||||||
 | 
					        ctime = os.path.getctime(vagrantfile)
 | 
				
			||||||
 | 
					        vagrant_up.assert_called_once()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        time.sleep(0.01)  # ensure reliable failure when testing ctime
 | 
				
			||||||
 | 
					        up.run_vagrant(APPID, VERCODE, 1, 1)
 | 
				
			||||||
 | 
					        vagrant_destroy.assert_called_once()
 | 
				
			||||||
 | 
					        self.assertNotEqual(ctime, os.path.getctime(vagrantfile))
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue