new subcommand "exec": podman implementation

This commit is contained in:
Hans-Christoph Steiner 2025-10-08 20:25:25 +02:00
parent fd60436aa3
commit 63660e1aed
4 changed files with 176 additions and 0 deletions

View file

@ -66,6 +66,7 @@ COMMANDS = OrderedDict([
# modify the local environment, or even run things as root. # modify the local environment, or even run things as root.
COMMANDS_INTERNAL = [ COMMANDS_INTERNAL = [
"destroy", "destroy",
"exec",
"up", "up",
] ]

View file

@ -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
@ -110,6 +111,11 @@ FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
SUPPORTED_VIRT_CONTAINER_TYPES = ('podman', 'vagrant') 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'
@ -5129,6 +5135,42 @@ def get_podman_container(appid, vercode):
return ret 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): def get_vagrantfile_path(appid, vercode):
"""Return the path for the unique VM for a given build. """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') 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): def vagrant_destroy(appid, vercode):
import vagrant import vagrant

66
fdroidserver/exec.py Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
#
# exec.py - part of the FDroid server tools
# Copyright (C) 2024-2025, Hans-Christoph Steiner <hans@eds.org>
#
# 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."
)
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()

63
tests/test_exec.py Executable file
View 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)