Skip to content

Commit

Permalink
Allow running Linux System apps for foreign targets
Browse files Browse the repository at this point in the history
  • Loading branch information
rmartin16 committed Jan 16, 2024
1 parent 246ce7d commit 1dcf21c
Show file tree
Hide file tree
Showing 12 changed files with 1,068 additions and 123 deletions.
1 change: 1 addition & 0 deletions changes/1603.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``briefcase run`` command now supports the ``--target`` option to run Linux apps from within Docker for other distributions.
8 changes: 8 additions & 0 deletions src/briefcase/bootstraps/toga.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def pyproject_table_linux_system_debian(self):
"libcairo2-dev",
# Needed to compile PyGObject wheel
"libgirepository1.0-dev",
# Needed to run the app
"gir1.2-gtk-3.0",
# "gir1.2-webkit2-4.0",
]
system_runtime_requires = [
Expand All @@ -91,6 +94,8 @@ def pyproject_table_linux_system_rhel(self):
"cairo-gobject-devel",
# Needed to compile PyGObject wheel
"gobject-introspection-devel",
# Needed to run the app
"gtk3",
]
system_runtime_requires = [
Expand All @@ -112,6 +117,9 @@ def pyproject_table_linux_system_suse(self):
"cairo-devel",
# Needed to compile PyGObject wheel
"gobject-introspection-devel",
# Needed to run the app
"gtk3", "typelib-1_0-Gtk-3_0",
# "libwebkit2gtk3", "typelib-1_0-WebKit2-4_1",
]
system_runtime_requires = [
Expand Down
130 changes: 113 additions & 17 deletions src/briefcase/integrations/docker.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import contextlib
import os
import subprocess
import sys
Expand Down Expand Up @@ -386,6 +387,9 @@ class DockerAppContext(Tool):
name = "docker_app_context"
full_name = "Docker"

XSOCKET = PurePosixPath("/tmp/.X11-unix")
XAUTHORITY = Path("/tmp/.briefcase.docker.xauth")

def __init__(self, tools: ToolCache, app: AppConfig):
super().__init__(tools=tools)
self.app: AppConfig = app
Expand Down Expand Up @@ -521,9 +525,9 @@ def _dockerize_args(
self,
args: SubprocessArgsT,
interactive: bool = False,
mounts: list[tuple[str | Path, str | Path]] | None = None,
env: dict[str, str] | None = None,
cwd: Path | None = None,
mounts: list[tuple[str | os.PathLike, str | os.PathLike]] | None = None,
env: dict[str, str | os.PathLike] | None = None,
cwd: str | os.PathLike | None = None,
) -> list[str]: # pragma: no-cover-if-is-windows
"""Convert arguments and environment into a Docker-compatible form. Convert an
argument and environment specification into a form that can be used as arguments
Expand All @@ -546,11 +550,10 @@ def _dockerize_args(
if interactive:
docker_args.append("-it")

# Add default volume mounts for the app folder, plus the Briefcase data
# path.
# Add default volume mounts for the app folder, plus the Briefcase data path.
#
# The :z suffix on volume mounts allows SELinux to modify the host
# mount; it is ignored on non-SELinux platforms.
# The :z suffix on volume mounts allows SELinux to modify the host mount; it
# is ignored on non-SELinux platforms.
docker_args.extend(
[
"--volume",
Expand All @@ -565,11 +568,13 @@ def _dockerize_args(
for source, target in mounts:
docker_args.extend(["--volume", f"{source}:{target}:z"])

# If any environment variables have been defined, pass them in
# as --env arguments to Docker.
# If any environment variables have been defined, pass them in as --env
# arguments to Docker.
if env:
for key, value in env.items():
docker_args.extend(["--env", f"{key}={self._dockerize_path(value)}"])
docker_args.extend(
["--env", f"{key}={self._dockerize_path(os.fsdecode(value))}"]
)

# If a working directory has been specified, pass it
if cwd:
Expand All @@ -579,17 +584,108 @@ def _dockerize_args(
docker_args.append(self.image_tag)

# ... then add the command (and its arguments) to run in the container
docker_args.extend([self._dockerize_path(str(arg)) for arg in args])
docker_args.extend([self._dockerize_path(os.fsdecode(arg)) for arg in args])

return docker_args

@contextlib.contextmanager
def _x11_passthrough(self, subprocess_kwargs: dict[str, ...]) -> dict[str, ...]:
"""Manager to expose the host's X11 server to a container.
This allows Docker containers to use the X11 UNIX socket with the authorization
afforded to the current user. The user's X11 auth is copied and modified in a
dedicated temporary file; this X11 auth file and the host's X11 socket are bind-
mounted in to the container and specific X11 environment variables ensure any
graphical apps in the container leverage this configuration to display windows
using the host machine's X11 server.
"""
if not (DISPLAY := self.tools.os.getenv("DISPLAY")):
raise BriefcaseCommandError(
"The DISPLAY environment variable must be set to run an app in Docker"
)

if not self.tools.shutil.which("xauth"):
raise BriefcaseCommandError(
"Install xauth to run an app for a targeted Linux distribution"
)

# cleanup any lingering X11 auth file
self.XAUTHORITY.unlink(missing_ok=True)

# request current X11 auth for user
try:
xauth_list = self.tools.subprocess.check_output(["xauth", "nlist", DISPLAY])
except subprocess.CalledProcessError as e:
raise BriefcaseCommandError("Failed to retrieve xauth list") from e

# ensure X11 auth will work regardless of the container's hostname by converting
# the hostname tag to a "FamilyWild" tag that allows *any* hostname to connect
xauth_list = "\n".join("ffff" + line[4:] for line in xauth_list.splitlines())

# write the X11 auth file that will be exposed to the container
try:
self.XAUTHORITY.touch()
self.tools.subprocess.check_output(
["xauth", "-f", self.XAUTHORITY, "nmerge", "-"],
input=xauth_list,
)
except subprocess.CalledProcessError as e:
self.XAUTHORITY.unlink(missing_ok=True)
raise BriefcaseCommandError("Failed to write xauth for container") from e

subprocess_kwargs.setdefault("env", {}).update(
{
"DISPLAY": DISPLAY,
"XAUTHORITY": self.XAUTHORITY,
}
)
subprocess_kwargs.setdefault("mounts", []).extend(
[
(self.XSOCKET, self.XSOCKET),
(self.XAUTHORITY, self.XAUTHORITY),
]
)

try:
yield subprocess_kwargs
finally:
self.XAUTHORITY.unlink(missing_ok=True)

@contextlib.contextmanager
def run_app_context(self, subprocess_kwargs: dict[str, ...]) -> dict[str, ...]:
"""Manager to run a Briefcase project app in a container.
:returns: context manager to wrap the call to Popen/run/check_output()
"""
with self._x11_passthrough(subprocess_kwargs) as subprocess_kwargs:
yield subprocess_kwargs

def Popen(
self,
args: SubprocessArgsT,
env: dict[str, str | os.PathLike] | None = None,
cwd: str | os.PathLike | None = None,
mounts: list[tuple[str | os.PathLike, str | os.PathLike]] | None = None,
**kwargs,
):
"""Open and return a process running inside a Docker container."""
return self.tools.subprocess.Popen(
self._dockerize_args(
args,
mounts=mounts,
env=env,
cwd=cwd,
),
**kwargs,
)

def run(
self,
args: SubprocessArgsT,
env: dict[str, str] | None = None,
cwd: Path | None = None,
env: dict[str, str | os.PathLike] | None = None,
cwd: str | os.PathLike | None = None,
interactive: bool = False,
mounts: list[tuple[str | Path, str | Path]] | None = None,
mounts: list[tuple[str | os.PathLike, str | os.PathLike]] | None = None,
**kwargs,
):
"""Run a process inside a Docker container."""
Expand All @@ -614,9 +710,9 @@ def run(
def check_output(
self,
args: SubprocessArgsT,
env: dict[str, str] | None = None,
cwd: Path | None = None,
mounts: list[tuple[str | Path, str | Path]] | None = None,
env: dict[str, str | os.PathLike] | None = None,
cwd: str | os.PathLike | None = None,
mounts: list[tuple[str | os.PathLike, str | os.PathLike]] | None = None,
**kwargs,
) -> str:
"""Run a process inside a Docker container, capturing output."""
Expand Down
10 changes: 10 additions & 0 deletions src/briefcase/integrations/subprocess.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import contextlib
import json
import operator
import os
Expand Down Expand Up @@ -177,6 +178,15 @@ def prepare(self):
# This is a no-op; the native subprocess environment is ready-to-use.
pass

@contextlib.contextmanager
def run_app_context(self, subprocess_kwargs: dict[str, ...]) -> dict[str, ...]:
"""A manager to wrap subprocess calls to run a Briefcase project app.
:param subprocess_kwargs: initialized keyword arguments for subprocess calls
"""
# This is a no-op; the native subprocess environment is ready-to-use.
yield subprocess_kwargs

def full_env(self, overrides: dict[str, str]) -> dict[str, str]:
"""Generate the full environment in which the command will run.
Expand Down
84 changes: 35 additions & 49 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,26 @@ class LinuxSystemPassiveMixin(LinuxMixin):

@property
def use_docker(self):
# The passive mixing doesn't expose the `--target` option, as it can't use
# Docker. However, we need the use_docker property to exist so that the
# app config can be finalized in the general case.
return False
# The system backend doesn't have a literal "--use-docker" option, but
# `use_docker` is a useful flag for shared logic purposes, so evaluate
# what "use docker" means in terms of target_image.
return bool(self.target_image)

def add_options(self, parser):
super().add_options(parser)
parser.add_argument(
"--target",
dest="target",
help="Docker base image tag for the distribution to target for the build (e.g., `ubuntu:jammy`)",
required=False,
)

def parse_options(self, extra):
# The passive mixin doesn't expose the `--target` option, but if run infers
# build, we need target image to be defined.
options = super().parse_options(extra)
self.target_image = None
"""Extract the target_image option."""
options, overrides = super().parse_options(extra)
self.target_image = options.pop("target")

return options
return options, overrides

def build_path(self, app):
# Override the default build path to use the vendor name,
Expand Down Expand Up @@ -196,13 +204,6 @@ class LinuxSystemMostlyPassiveMixin(LinuxSystemPassiveMixin):
# The Mostly Passive mixin verifies that Docker exists and can be run, but
# doesn't require that we're actually in a Linux environment.

@property
def use_docker(self):
# The system backend doesn't have a literal "--use-docker" option, but
# `use_docker` is a useful flag for shared logic purposes, so evaluate
# what "use docker" means in terms of target_image.
return bool(self.target_image)

def app_python_version_tag(self, app: AppConfig):
if self.use_docker:
# If we're running in Docker, we can't know the Python3 version
Expand Down Expand Up @@ -372,22 +373,6 @@ def verify_tools(self):
if self.use_docker:
Docker.verify(tools=self.tools, image_tag=self.target_image)

def add_options(self, parser):
super().add_options(parser)
parser.add_argument(
"--target",
dest="target",
help="Docker base image tag for the distribution to target for the build (e.g., `ubuntu:jammy`)",
required=False,
)

def parse_options(self, extra):
"""Extract the target_image option."""
options, overrides = super().parse_options(extra)
self.target_image = options.pop("target")

return options, overrides

def clone_options(self, command):
"""Clone the target_image option."""
super().clone_options(command)
Expand Down Expand Up @@ -792,7 +777,7 @@ def build_app(self, app: AppConfig, **kwargs):
self.tools.subprocess.check_output(["strip", self.binary_path(app)])


class LinuxSystemRunCommand(LinuxSystemPassiveMixin, RunCommand):
class LinuxSystemRunCommand(LinuxSystemMixin, RunCommand):
description = "Run a Linux system project."
supported_host_os = {"Linux"}
supported_host_os_reason = "Linux system projects can only be executed on Linux."
Expand All @@ -809,23 +794,24 @@ def run_app(
# Set up the log stream
kwargs = self._prepare_app_env(app=app, test_mode=test_mode)

# Start the app in a way that lets us stream the logs
app_popen = self.tools.subprocess.Popen(
[os.fsdecode(self.binary_path(app))] + passthrough,
cwd=self.tools.home_path,
**kwargs,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
with self.tools[app].app_context.run_app_context(kwargs) as kwargs:
# Start the app in a way that lets us stream the logs
app_popen = self.tools[app].app_context.Popen(
[os.fsdecode(self.binary_path(app))] + passthrough,
cwd=self.tools.home_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
**kwargs,
)

# Start streaming logs for the app.
self._stream_app_logs(
app,
popen=app_popen,
test_mode=test_mode,
clean_output=False,
)
# Start streaming logs for the app.
self._stream_app_logs(
app,
popen=app_popen,
test_mode=test_mode,
clean_output=False,
)


def debian_multiline_description(description):
Expand Down
Loading

0 comments on commit 1dcf21c

Please sign in to comment.