diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 787226e7..b322f4a7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -38,6 +38,14 @@ jobs: default: true override: true + - name: Disable AppArmor restriction for unprivileged user namespaces + if: matrix.os == 'ubuntu-latest' + run: sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Check unshare is working + if: matrix.os == 'ubuntu-latest' + run: unshare -rn echo "unshare works" + - name: Install dependencies run: python -m pip install tox @@ -116,6 +124,14 @@ jobs: default: true override: true + - name: Disable AppArmor restriction for unprivileged user namespaces + if: matrix.os == 'ubuntu-latest' + run: sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Check unshare is working + if: matrix.os == 'ubuntu-latest' + run: unshare -rn echo "unshare works" + - name: Install dependencies run: python -m pip install tox diff --git a/src/fromager/external_commands.py b/src/fromager/external_commands.py index 65e5d338..3ab57482 100644 --- a/src/fromager/external_commands.py +++ b/src/fromager/external_commands.py @@ -1,7 +1,7 @@ import logging import os +import pathlib import shlex -import shutil import subprocess import sys import typing @@ -9,9 +9,12 @@ logger = logging.getLogger(__name__) +HERE = pathlib.Path(__file__).absolute().parent + NETWORK_ISOLATION: list[str] | None if sys.platform == "linux": - NETWORK_ISOLATION = ["unshare", "--net", "--map-current-user"] + # runner script with `unshare -rn` + `ip link set lo up` + NETWORK_ISOLATION = [str(HERE / "run_network_isolation.sh")] else: NETWORK_ISOLATION = None @@ -22,11 +25,8 @@ def network_isolation_cmd() -> typing.Sequence[str]: Raises ValueError when network isolation is not supported Returns: command list to run a process with network isolation """ - if sys.platform == "linux": - unshare = shutil.which("unshare") - if unshare is not None: - return [unshare, "--net", "--map-current-user"] - raise ValueError("Linux system without 'unshare' command") + if NETWORK_ISOLATION: + return NETWORK_ISOLATION raise ValueError(f"unsupported platform {sys.platform}") @@ -106,6 +106,7 @@ def run( # isolation problem and change the exception type to make it easier # for the caller to recognize that case. for substr in [ + "connection refused", "network unreachable", "Network is unreachable", ]: diff --git a/src/fromager/run_network_isolation.sh b/src/fromager/run_network_isolation.sh new file mode 100755 index 00000000..42e082f4 --- /dev/null +++ b/src/fromager/run_network_isolation.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env -S unshare -rn /bin/bash +# +# Run command with network isolation (CLONE_NEWNET) and set up loopback +# interface in the new network namespace. This is somewhat similar to +# Bubblewrap `bwrap --unshare-net --dev-bind / /`, but works in an +# unprivilged container. The user is root inside the new namespace and mapped +# to the euid/egid if the parent namespace. +# +# Ubuntu 24.04: needs `sysctl kernel.apparmor_restrict_unprivileged_userns=0` +# to address `unshare: write failed /proc/self/uid_map: Operation not permitted`. +# + +set -e +set -o pipefail + +if [ "$#" -eq 0 ]; then + echo "$0 command" >&2 + exit 2 +fi + +# bring loopback up +ip link set lo up + +# replace with command +exec "$@" diff --git a/tests/test_external_commands.py b/tests/test_external_commands.py index cccd0202..84d9cdac 100644 --- a/tests/test_external_commands.py +++ b/tests/test_external_commands.py @@ -62,16 +62,19 @@ def test_external_commands_network_isolation( ) +NETWORK_ISOLATION_ERROR: Exception | None = None try: external_commands.detect_network_isolation() -except Exception: +except Exception as err: + NETWORK_ISOLATION_ERROR = err SUPPORTS_NETWORK_ISOLATION: bool = False else: SUPPORTS_NETWORK_ISOLATION = True @pytest.mark.skipif( - not SUPPORTS_NETWORK_ISOLATION, reason="network isolation is not supported" + not SUPPORTS_NETWORK_ISOLATION, + reason=f"network isolation is not supported: {NETWORK_ISOLATION_ERROR}", ) def test_external_commands_network_isolation_real(): with pytest.raises(external_commands.NetworkIsolationError) as e: