diff --git a/fdroidserver/__main__.py b/fdroidserver/__main__.py index 0e5a2386..bbfa1ebb 100755 --- a/fdroidserver/__main__.py +++ b/fdroidserver/__main__.py @@ -66,6 +66,7 @@ COMMANDS = OrderedDict([ # modify the local environment, or even run things as root. COMMANDS_INTERNAL = [ "destroy", + "exec", "up", ] diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 86440109..627d4cf5 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -67,6 +67,7 @@ import logging import operator import os import re +import shlex import shutil import socket import stat @@ -110,6 +111,11 @@ 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' @@ -5129,6 +5135,42 @@ def get_podman_container(appid, vercode): return ret +def inside_exec(appid, vercode, command, virt_container_type): + """Execute the command inside of the VM for the build.""" + if virt_container_type == 'vagrant': + return vagrant_exec(appid, vercode, command) + elif virt_container_type == 'podman': + return podman_exec(appid, vercode, command) + else: + raise Exception( + f"'{virt_container_type}' not supported, currently supported: vagrant, podman" + ) + + +def podman_exec(appid, vercode, command): + """Execute the command inside of a podman container for the build.""" + container_name = get_container_name(appid, vercode) + to_stdin = shlex.join(command) + p = subprocess.run( + [ + 'podman', + 'exec', + '--interactive', + '--user=vagrant', + 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. @@ -5139,6 +5181,10 @@ def get_vagrantfile_path(appid, vercode): return Path('tmp/buildserver', get_container_name(appid, vercode), 'Vagrantfile') +def vagrant_exec(appid, vercode, command): + """Execute a command in the Vagrant VM via ssh.""" + + def vagrant_destroy(appid, vercode): import vagrant diff --git a/fdroidserver/exec.py b/fdroidserver/exec.py new file mode 100644 index 00000000..2a11e5b7 --- /dev/null +++ b/fdroidserver/exec.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# +# exec.py - part of the FDroid server tools +# Copyright (C) 2024-2025, Hans-Christoph Steiner +# +# 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 . + +"""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." + ) + 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), + ) + except Exception as e: + if options.verbose: + logging.error(traceback.format_exc()) + else: + logging.error(e) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_exec.py b/tests/test_exec.py new file mode 100755 index 00000000..04111942 --- /dev/null +++ b/tests/test_exec.py @@ -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)