Skip to content

Commit

Permalink
Add support for Docker Desktop and rootless Docker
Browse files Browse the repository at this point in the history
  • Loading branch information
rmartin16 committed Jun 26, 2023
1 parent beda029 commit f06f299
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 39 deletions.
1 change: 1 addition & 0 deletions changes/1083.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On Linux, Docker Desktop and rootless Docker are now supported.
160 changes: 132 additions & 28 deletions src/briefcase/integrations/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,17 @@ class Docker(Tool):
}

@classmethod
def verify_install(cls, tools: ToolCache, **kwargs) -> Docker:
"""Verify Docker is installed and operational."""
def verify_install(
cls,
tools: ToolCache,
image_tag: str | None = None,
**kwargs,
) -> Docker:
"""Verify Docker is installed and operational.
:param tools: ToolCache of available tools
:param image_tag: Specific image to use to assess Docker's operating mode
"""
# short circuit since already verified and available
if hasattr(tools, "docker"):
return tools.docker
Expand All @@ -123,6 +132,8 @@ def verify_install(cls, tools: ToolCache, **kwargs) -> Docker:
cls._buildx_installed(tools=tools)

tools.docker = Docker(tools=tools)
tools.docker._determine_docker_mode(image_tag=image_tag)

return tools.docker

@classmethod
Expand Down Expand Up @@ -189,16 +200,131 @@ def _buildx_installed(cls, tools: ToolCache):
except subprocess.CalledProcessError:
raise BriefcaseCommandError(cls.BUILDX_PLUGIN_MISSING)

def _determine_docker_mode(self, image_tag: str | None = None):
"""Determine Docker's operating mode from how it interacts with bind mounts.
Docker can be installed in different ways on Linux that significantly impact how
containers interact with the host system. Of particular note is ownership of
files and directories in bind mounts (i.e. mounts using --volume).
Traditionally, Docker would pass through the UID/GID of the user used inside a
container as the owner of files created within the bind mount. And since the
default user inside containers is root, the files would be owned by root on the
host file system; this prevents later interaction with those files by the host.
To work around this, the Dockerfile can use a step-down user with a UID and GID
that matches the host user running Docker.
Other installation methods of Docker, though, are not compatible with using such
a step-down user. This includes Docker Desktop and rootless Docker. In these
modes, Docker maps the host user to the root user inside the container; this
mapping is transparent and would require changes to the host environment to
disable, if it can be disabled at all. This allows files created in bind mounts
inside the container to be owned on the host file system by the user running
Docker. Additionally, though, because the host user is mapped to root inside the
container, any files that were created by the host user in the bind mount
outside the container are owned by root inside the container; therefore, a step-
down user could not interact with such bind mount files inside the container.
To accommodate these different modes, this checks which user owns a file that is
created inside a bind mount in the container. If the owning user of that file on
the host file system is root, then a step-down user is necessary inside
containers. If the owning user is the host user, root should be used.
"""
write_test_filename = "container_write_test"
host_write_test_dir_path = Path.cwd()
host_write_test_file_path = Path(host_write_test_dir_path, write_test_filename)
container_mount_host_dir = "/host_write_test"
container_write_test_file_path = PurePosixPath(
container_mount_host_dir, write_test_filename
)

docker_run_cmd = [
"docker",
"run",
"--rm",
"--volume",
f"{host_write_test_dir_path}:{container_mount_host_dir}:z",
"alpine" if image_tag is None else image_tag,
]

try:
host_write_test_file_path.unlink(missing_ok=True)
except OSError as e:
raise BriefcaseCommandError(
f"""
The file path used to determine Docker's operating mode already exists and
cannot be automatically deleted.
{host_write_test_file_path}
Delete this file and run Briefcase again.
"""
) from e

try:
self.tools.subprocess.run(
docker_run_cmd + ["touch", container_write_test_file_path],
check=True,
stream_output=False,
)
except subprocess.CalledProcessError as e:
raise BriefcaseCommandError(
"Unable to determine Docker's operating mode"
) from e

# if the file is not owned by `root`, then Docker is mapping usernames
self.is_userns_remap = 0 != self.tools.os.stat(host_write_test_file_path).st_uid

try:
self.tools.subprocess.run(
docker_run_cmd + ["rm", "-f", container_write_test_file_path],
check=True,
stream_output=False,
)
except subprocess.CalledProcessError as e:
raise BriefcaseCommandError(
"Unable to clean up from determining Docker's operating mode"
) from e

def cache_image(self, image_tag: str):
"""Ensures an image is available and cached locally.
While many Docker commands for an image will pull that image in-line with the
command if it isn't already cached, this pollutes the console output with
details about pulling the image. This can be particularly troublesome when the
output from a command run inside a container using the image is desired.
Note: This will not update an already cached image if a newer version is
available in the registry.
:param image_tag: Image name/tag to pull if not locally cached
"""
image_id = self.tools.subprocess.check_output(
["docker", "images", "-q", image_tag]
).strip()

if not image_id:
try:
self.tools.subprocess.run(["docker", "pull", image_tag])
except subprocess.CalledProcessError as e:
raise BriefcaseCommandError(
f"Unable to obtain the Docker image for {image_tag}. "
"Is the image name correct?"
) from e

def check_output(self, args: list[SubprocessArgT], image_tag: str) -> str:
"""Run a process inside a Docker container, capturing output.
This is a bare Docker invocation; it's really only useful for running simple
commands on an image, ensuring that the container is destroyed afterward. In
most cases, you'll want to use an app context, rather than this.
This ensures the image is locally cached and then runs a bare Docker invocation;
it's really only useful for running simple commands on an image, ensuring that
the container is destroyed afterward. In most cases, you'll want to use an app
context, rather than this.
:param args: The list of arguments to pass to the Docker instance.
:param image_tag: The Docker image to run
"""
self.cache_image(image_tag)

# Any exceptions from running the process are *not* caught.
# This ensures that "docker.check_output()" behaves as closely to
# "subprocess.check_output()" as possible.
Expand All @@ -212,28 +338,6 @@ def check_output(self, args: list[SubprocessArgT], image_tag: str) -> str:
+ args,
)

def prepare(self, image_tag: str):
"""Ensure that the given image exists, and is cached locally.
This is achieved by trying to run a no-op command (echo) on the image; if it
succeeds, the image exists locally.
A pull is forced, so you can be certain that the image is up-to-date.
:param image_tag: The Docker image to prepare
"""
try:
self.tools.subprocess.run(
["docker", "run", "--rm", image_tag, "printf", ""],
check=True,
stream_output=False,
)
except subprocess.CalledProcessError as e:
raise BriefcaseCommandError(
f"Unable to obtain the Docker base image {image_tag}. "
"Is the image name correct?"
) from e


class DockerAppContext(Tool):
name = "docker_app_context"
Expand Down Expand Up @@ -354,7 +458,7 @@ def _dockerize_path(self, arg: str) -> str: # pragma: no-cover-if-is-windows
filesystem.
Converts:
* any reference to sys.executable into the python executable in the docker container
* any reference to `sys.executable` into the python executable in the docker container
* any path in <build path> into the equivalent stemming from /app
* any path in <data path> into the equivalent in ~/.cache/briefcase
Expand Down
6 changes: 5 additions & 1 deletion src/briefcase/platforms/linux/appimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,12 @@ class LinuxAppImageCreateCommand(

def output_format_template_context(self, app: AppConfig):
context = super().output_format_template_context(app)
# Add the manylinux tag to the template context.

try:
# Use the non-root brutus user if Docker is not running rootless
context["use_non_root_user"] = not self.tools.docker.is_userns_remap

# Add the manylinux tag to the template context.
tag = getattr(app, "manylinux_image_tag", "latest")
context["manylinux_image"] = f"{app.manylinux}_{self.tools.host_arch}:{tag}"
if app.manylinux in {"manylinux1", "manylinux2010", "manylinux2014"}:
Expand Down
25 changes: 15 additions & 10 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from typing import List

Expand Down Expand Up @@ -271,16 +272,15 @@ def platform_freedesktop_info(self, app):
# Preserve the target image on the command line as the app's target
app.target_image = self.target_image

# Ensure that the Docker base image is available.
self.logger.info(f"Checking Docker target image {app.target_image}...")
self.tools.docker.prepare(app.target_image)

# Extract release information from the image.
output = self.tools.docker.check_output(
["cat", "/etc/os-release"],
image_tag=app.target_image,
)
freedesktop_info = parse_freedesktop_os_release(output)
with self.input.wait_bar(
f"Checking Docker target image {app.target_image}..."
):
output = self.tools.docker.check_output(
["cat", "/etc/os-release"],
image_tag=app.target_image,
)
freedesktop_info = parse_freedesktop_os_release(output)
else:
freedesktop_info = super().platform_freedesktop_info(app)

Expand All @@ -294,7 +294,7 @@ def verify_tools(self):
"""If we're using Docker, verify that it is available."""
super().verify_tools()
if self.use_docker:
Docker.verify(tools=self.tools)
Docker.verify(tools=self.tools, image_tag=self.target_image)

def add_options(self, parser):
super().add_options(parser)
Expand Down Expand Up @@ -559,6 +559,11 @@ def output_format_template_context(self, app: AppConfig):
# Add the vendor base
context["vendor_base"] = app.target_vendor_base

# Use the non-root brutus user if Docker is not running rootless
# (only relevant if Docker is being used for the target platform)
with suppress(AttributeError):
context["use_non_root_user"] = not self.tools.docker.is_userns_remap

return context


Expand Down

0 comments on commit f06f299

Please # to comment.