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 signindex --verbose
 | 
			
		||||
    - 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")),
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
# 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):
 | 
			
		||||
    print(_("usage: ") + _("fdroid [<command>] [-h|--help|--version|<args>]"))
 | 
			
		||||
| 
						 | 
				
			
			@ -136,7 +153,12 @@ def main():
 | 
			
		|||
        sys.exit(0)
 | 
			
		||||
 | 
			
		||||
    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'):
 | 
			
		||||
            print_help(available_plugins=available_plugins)
 | 
			
		||||
            sys.exit(0)
 | 
			
		||||
| 
						 | 
				
			
			@ -186,7 +208,7 @@ def main():
 | 
			
		|||
    sys.argv[0] += ' ' + command
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
        command = 'import_subcommand' if command == 'import' else command
 | 
			
		||||
        mod = __import__('fdroidserver.' + command, None, None, [command])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,10 +4,10 @@
 | 
			
		|||
#
 | 
			
		||||
# Copyright (C) 2010-2016, Ciaran Gultnieks, ciaran@ciarang.com
 | 
			
		||||
# 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, 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) 2019-2021, Jochen Sprickerhof <git@jochen.sprickerhof.de>
 | 
			
		||||
# Copyright (C) 2021, Felix C. Stegerman <flx@obfusk.net>
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +67,7 @@ import logging
 | 
			
		|||
import operator
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import shlex
 | 
			
		||||
import shutil
 | 
			
		||||
import socket
 | 
			
		||||
import stat
 | 
			
		||||
| 
						 | 
				
			
			@ -82,7 +83,7 @@ from datetime import datetime, timedelta, timezone
 | 
			
		|||
from pathlib import Path
 | 
			
		||||
from queue import Queue
 | 
			
		||||
from typing import List
 | 
			
		||||
from urllib.parse import urlparse, urlsplit, urlunparse
 | 
			
		||||
from urllib.parse import urlparse, urlsplit, urlunparse, unquote
 | 
			
		||||
from zipfile import ZipFile
 | 
			
		||||
 | 
			
		||||
import defusedxml.ElementTree as XMLElementTree
 | 
			
		||||
| 
						 | 
				
			
			@ -108,6 +109,13 @@ from .looseversion import LooseVersion
 | 
			
		|||
# The path to this fdroidserver distribution
 | 
			
		||||
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.
 | 
			
		||||
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):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, msg):
 | 
			
		||||
| 
						 | 
				
			
			@ -1046,6 +1075,27 @@ def get_local_metadata_files():
 | 
			
		|||
    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):
 | 
			
		||||
    """No summary.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1259,6 +1309,16 @@ def get_source_date_epoch(build_dir):
 | 
			
		|||
            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):
 | 
			
		||||
    """Get the dir that this app will be built in."""
 | 
			
		||||
    if app.RepoType == 'srclib':
 | 
			
		||||
| 
						 | 
				
			
			@ -5012,3 +5072,184 @@ FDROIDORG_MIRRORS = [
 | 
			
		|||
FDROIDORG_FINGERPRINT = (
 | 
			
		||||
    '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',
 | 
			
		||||
            'python-magic',
 | 
			
		||||
        ],
 | 
			
		||||
        'podman': ['podman'],
 | 
			
		||||
        'test': ['pyjks', 'html5print', 'testcontainers[minio]'],
 | 
			
		||||
        'docs': [
 | 
			
		||||
            'sphinx',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,11 @@ class VerboseFalseOptions:
 | 
			
		|||
    verbose = False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
APPID = 'com.example'
 | 
			
		||||
VERCODE = 123
 | 
			
		||||
APPID_VERCODE = f'{APPID}:{VERCODE}'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TmpCwd:
 | 
			
		||||
    """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.index.commit("add code")
 | 
			
		||||
        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