From e8adbf55c11531021336148ec7ca683c995a550f Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sat, 12 Jul 2025 13:46:33 +0900 Subject: [PATCH] feat: Add rootless Docker support (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add rootless Docker support Implements #547 - Add support for rootless Docker images to avoid permission issues. Key changes: - Add Dockerfile.rootless that runs as UID 1000 by default - Create simplified entrypoint script without chown operations - Add build-rootless.py to build rootless variants with -rootless suffix - Document rootless usage in README-ROOTLESS.md - Update main README with rootless section The rootless images eliminate common permission problems by: - Running as non-root from the start (USER 1000:1000) - Avoiding recursive chown operations that can cause race conditions - Using open permissions (777) on directories during build - Not supporting PUID/PGID environment variables This provides a cleaner solution for rootless Docker users and those experiencing permission issues with volumes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: Address linting issues in rootless Docker implementation - Add --no-install-recommends to apt-get install in Dockerfile - Consolidate consecutive RUN instructions in Dockerfile - Fix shellcheck warnings: quote variables and use -n instead of \! -z - These changes improve best practices without affecting functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: Add rootless image building to CI pipeline - Update docker-build.yml workflow to build rootless variants - Rootless images are built after regular images with -rootless suffix - Both use the same multi-architecture build process - Triggered automatically when buildinfo.json changes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: Unify build system for regular and rootless images - Create build-unified.py that handles both regular and rootless builds - Convert build.py and build-rootless.py to wrapper scripts for backwards compatibility - Update CI workflow to use unified build command - Add BUILD_MIGRATION.md documentation - Eliminate code duplication between build scripts - Support flexible build options: --rootless, --both, --only-stable-latest This maintains all existing functionality while providing a cleaner, more maintainable build system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: Add Python cache to .gitignore and remove from repo - Add __pycache__/ and Python compiled files to .gitignore - Remove accidentally committed __pycache__ directory - Prevent future Python cache files from being tracked 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: Replace build system with unified solution - Remove old build.py and build-rootless.py wrapper scripts - Rename build-unified.py to build.py as the main build script - Delete BUILD_MIGRATION.md (no longer needed) - Update CI workflow to use new build.py syntax - Update documentation in CLAUDE.md and README-ROOTLESS.md The new build system provides all functionality in a single script: - Default: builds regular images - --rootless: builds only rootless images - --both: builds both regular and rootless images - --multiarch and --push-tags: work as before This creates a cleaner, more maintainable build system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * docs: Consolidate rootless documentation and mark as experimental - Remove separate README-ROOTLESS.md file - Integrate rootless documentation into main README.md - Mark rootless support as experimental - Add clear documentation about limitations and use cases - Include warning about experimental nature This consolidates all documentation in one place and makes it clear that rootless support is still experimental. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .github/workflows/docker-build.yml | 4 +- .gitignore | 5 + CLAUDE.md | 20 +++- README.md | 53 +++++++++ build.py | 94 ++++++++++++---- docker/Dockerfile.rootless | 91 +++++++++++++++ docker/files/docker-entrypoint-rootless.sh | 124 +++++++++++++++++++++ 7 files changed, 362 insertions(+), 29 deletions(-) create mode 100644 docker/Dockerfile.rootless create mode 100755 docker/files/docker-entrypoint-rootless.sh diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 534fba9..0ce97a9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -20,10 +20,10 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - name: build and push + - name: build and push all images if: ${{ env.DOCKER_USERNAME != '' && env.DOCKER_PASSWORD != '' }} env: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} run: | - ./build.py --push-tags --multiarch \ No newline at end of file + ./build.py --push-tags --multiarch --both \ No newline at end of file diff --git a/.gitignore b/.gitignore index eb4b68f..d1bfbdc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ # IDE .idea + +# Python +__pycache__/ +*.py[cod] +*$py.class diff --git a/CLAUDE.md b/CLAUDE.md index 0559297..642f824 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,9 @@ This is a Docker image for running a Factorio headless server. It provides autom ### Key Components 1. **Docker Image Build System** - - `build.py` - Python script that builds Docker images from `buildinfo.json` + - `build.py` - Unified Python script that builds both regular and rootless Docker images from `buildinfo.json` - `docker/Dockerfile` - Main Dockerfile that creates the Factorio server image + - `docker/Dockerfile.rootless` - Dockerfile for rootless variant (runs as UID 1000) - `buildinfo.json` - Contains version info, SHA256 checksums, and tags for all supported versions - Supports multi-architecture builds (linux/amd64, linux/arm64) using Docker buildx @@ -38,11 +39,20 @@ This is a Docker image for running a Factorio headless server. It provides autom ### Building Images ```bash -# Build a single architecture image locally +# Build regular images locally (single architecture) python3 build.py -# Build and push multi-architecture images +# Build rootless images only +python3 build.py --rootless + +# Build both regular and rootless images +python3 build.py --both + +# Build and push multi-architecture images (regular only) python3 build.py --multiarch --push-tags + +# Build and push both regular and rootless multi-architecture images +python3 build.py --multiarch --push-tags --both ``` ### Running the Container @@ -109,6 +119,8 @@ Version updates are automated via GitHub Actions that run `update.sh` periodical ## Testing Changes 1. Modify `buildinfo.json` to test specific versions -2. Run `python3 build.py` to build locally +2. Run `python3 build.py` to build regular images locally + - Use `python3 build.py --rootless` for rootless images + - Use `python3 build.py --both` to build both variants 3. Test the container with your local data volume 4. For production changes, ensure `update.sh` handles version transitions correctly \ No newline at end of file diff --git a/README.md b/README.md index fad5cdc..ac05761 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,59 @@ stream { If your factorio host uses multiple IP addresses (very common with IPv6), you might additionally need to bind Factorio to a single IP (otherwise the UDP proxy might get confused with IP mismatches). To do that pass the `BIND` envvar to the container: `docker run --network=host -e BIND=2a02:1234::5678 ...` +## Rootless Docker Support (Experimental) + +> **Note**: Rootless support is currently experimental. Please report any issues you encounter. + +If you're experiencing permission issues or want better security, consider using the rootless images. These images are designed to work seamlessly with rootless Docker installations and avoid common permission problems. + +### What are Rootless Images? + +The rootless images differ from regular images in several ways: +- Run as UID 1000 (non-root) by default +- No dynamic UID/GID mapping (PUID/PGID not supported) +- No runtime chown operations +- All directories created with open permissions during build + +### Rootless Image Tags + +Each regular tag has a corresponding rootless version with the `-rootless` suffix: +- `latest-rootless` (experimental) +- `stable-rootless` (experimental) +- `2.0.55-rootless` (experimental) + +### Quick Start with Rootless + +```shell +docker run -d \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v ~/factorio:/factorio \ + --name factorio \ + --restart=unless-stopped \ + factoriotools/factorio:stable-rootless +``` + +Key differences: +- No `chown` command needed +- No PUID/PGID environment variables +- Runs as UID 1000 by default +- No permission issues with volumes + +### When to Use Rootless Images + +Consider using rootless images if you: +- Are running Docker in rootless mode +- Experience permission issues with volume mounts +- Want to avoid containers running as root +- Don't need dynamic UID/GID mapping via PUID/PGID + +### Limitations + +- PUID/PGID environment variables are not supported +- Fixed to UID 1000 (may not match your host user) +- Experimental feature - may have undiscovered issues + ## Troubleshooting ### My server is listed in the server browser, but nobody can connect diff --git a/build.py b/build.py index 71b83ff..0ba872a 100755 --- a/build.py +++ b/build.py @@ -6,6 +6,7 @@ import subprocess import shutil import sys import tempfile +import argparse PLATFORMS = [ @@ -25,9 +26,9 @@ def create_builder(build_dir, builder_name, platform): exit(1) -def build_and_push_multiarch(build_dir, build_args, push): - builder_name = "factoriotools-multiarch" - platform=",".join(PLATFORMS) +def build_and_push_multiarch(build_dir, build_args, push, builder_suffix=""): + builder_name = f"factoriotools{builder_suffix}-multiarch" + platform = ",".join(PLATFORMS) create_builder(build_dir, builder_name, platform) build_command = ["docker", "buildx", "build", "--platform", platform, "--builder", builder_name] + build_args if push: @@ -35,16 +36,16 @@ def build_and_push_multiarch(build_dir, build_args, push): try: subprocess.run(build_command, cwd=build_dir, check=True) except subprocess.CalledProcessError: - print("Build and push of image failed") + print(f"Build and push of {builder_suffix or 'regular'} image failed") exit(1) -def build_singlearch(build_dir, build_args): +def build_singlearch(build_dir, build_args, image_type="regular"): build_command = ["docker", "build"] + build_args try: subprocess.run(build_command, cwd=build_dir, check=True) except subprocess.CalledProcessError: - print("Build of image failed") + print(f"Build of {image_type} image failed") exit(1) @@ -58,16 +59,19 @@ def push_singlearch(tags): exit(1) -def build_and_push(sha256, version, tags, push, multiarch): +def build_and_push(sha256, version, tags, push, multiarch, dockerfile="Dockerfile", builder_suffix=""): build_dir = tempfile.mktemp() shutil.copytree("docker", build_dir) - build_args = ["--build-arg", f"VERSION={version}", "--build-arg", f"SHA256={sha256}", "."] + build_args = ["-f", dockerfile, "--build-arg", f"VERSION={version}", "--build-arg", f"SHA256={sha256}", "."] for tag in tags: build_args.extend(["-t", f"factoriotools/factorio:{tag}"]) + + image_type = "rootless" if "rootless" in dockerfile.lower() else "regular" + if multiarch: - build_and_push_multiarch(build_dir, build_args, push) + build_and_push_multiarch(build_dir, build_args, push, builder_suffix) else: - build_singlearch(build_dir, build_args) + build_singlearch(build_dir, build_args, image_type) if push: push_singlearch(tags) @@ -85,25 +89,69 @@ def login(): exit(1) -def main(push_tags=False, multiarch=False): +def generate_rootless_tags(original_tags): + """Generate rootless-specific tags from original tags""" + return [f"{tag}-rootless" for tag in original_tags] + + +def main(): + parser = argparse.ArgumentParser(description='Build Factorio Docker images') + parser.add_argument('--push-tags', action='store_true', help='Push images to Docker Hub') + parser.add_argument('--multiarch', action='store_true', help='Build multi-architecture images') + parser.add_argument('--rootless', action='store_true', help='Build only rootless images') + parser.add_argument('--both', action='store_true', help='Build both regular and rootless images') + parser.add_argument('--only-stable-latest', action='store_true', + help='Build only stable and latest versions (for rootless by default)') + + args = parser.parse_args() + + # Default behavior: build regular images unless specified otherwise + build_regular = not args.rootless or args.both + build_rootless = args.rootless or args.both + with open(os.path.join(os.path.dirname(__file__), "buildinfo.json")) as file_handle: builddata = json.load(file_handle) - if push_tags: + if args.push_tags: login() + # Filter versions if needed + versions_to_build = [] for version, buildinfo in sorted(builddata.items(), key=lambda item: item[0], reverse=True): - sha256 = buildinfo["sha256"] - tags = buildinfo["tags"] - build_and_push(sha256, version, tags, push_tags, multiarch) + if args.only_stable_latest or (build_rootless and not build_regular): + # For rootless-only builds, default to stable/latest only + if "stable" in buildinfo["tags"] or "latest" in buildinfo["tags"]: + versions_to_build.append((version, buildinfo)) + else: + versions_to_build.append((version, buildinfo)) + + # Build regular images + if build_regular: + print("Building regular images...") + for version, buildinfo in versions_to_build: + sha256 = buildinfo["sha256"] + tags = buildinfo["tags"] + build_and_push(sha256, version, tags, args.push_tags, args.multiarch) + + # Build rootless images + if build_rootless: + print("Building rootless images...") + # For rootless, only build stable and latest unless building both + rootless_versions = [] + if not build_regular or args.only_stable_latest: + for version, buildinfo in builddata.items(): + if "stable" in buildinfo["tags"] or "latest" in buildinfo["tags"]: + rootless_versions.append((version, buildinfo)) + else: + rootless_versions = versions_to_build + + for version, buildinfo in rootless_versions: + sha256 = buildinfo["sha256"] + original_tags = buildinfo["tags"] + rootless_tags = generate_rootless_tags(original_tags) + build_and_push(sha256, version, rootless_tags, args.push_tags, args.multiarch, + dockerfile="Dockerfile.rootless", builder_suffix="-rootless") if __name__ == '__main__': - push_tags = False - multiarch = False - for arg in sys.argv[1:]: - if arg == "--push-tags": - push_tags = True - elif arg == "--multiarch": - multiarch = True - main(push_tags, multiarch) + main() \ No newline at end of file diff --git a/docker/Dockerfile.rootless b/docker/Dockerfile.rootless new file mode 100644 index 0000000..078b89f --- /dev/null +++ b/docker/Dockerfile.rootless @@ -0,0 +1,91 @@ +# build rcon client +FROM debian:stable-slim AS rcon-builder +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install build-essential --no-install-recommends + +WORKDIR /src +COPY rcon/ /src +RUN make + +# build factorio image +FROM debian:stable-slim +LABEL maintainer="https://github.com/factoriotools/factorio-docker" + +ARG BOX64_VERSION=v0.2.4 + +# optionally utilize a built-in map-gen-preset (see data/base/prototypes/map-gen-presets +ARG PRESET + +# number of retries that curl will use when pulling the headless server tarball +ARG CURL_RETRIES=8 + +ENV PORT=34197 \ + RCON_PORT=27015 \ + SAVES=/factorio/saves \ + PRESET="$PRESET" \ + CONFIG=/factorio/config \ + MODS=/factorio/mods \ + SCENARIOS=/factorio/scenarios \ + SCRIPTOUTPUT=/factorio/script-output \ + DLC_SPACE_AGE="true" + +SHELL ["/bin/bash", "-eo", "pipefail", "-c"] + +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install ca-certificates curl jq pwgen xz-utils procps gettext-base --no-install-recommends \ + && if [[ "$(uname -m)" == "aarch64" ]]; then \ + echo "installing ARM compatability layer" \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install unzip --no-install-recommends \ + && curl -LO https://github.com/ptitSeb/box64/releases/download/${BOX64_VERSION}/box64-GENERIC_ARM-RelWithDebInfo.zip \ + && unzip box64-GENERIC_ARM-RelWithDebInfo.zip -d /bin \ + && rm -f box64-GENERIC_ARM-RelWithDebInfo.zip \ + && chmod +x /bin/box64; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +# version checksum of the archive to download +ARG VERSION +ARG SHA256 + +LABEL factorio.version=${VERSION} + +ENV VERSION=${VERSION} \ + SHA256=${SHA256} + +RUN set -ox pipefail \ + && if [[ "${VERSION}" == "" ]]; then \ + echo "build-arg VERSION is required" \ + && exit 1; \ + fi \ + && if [[ "${SHA256}" == "" ]]; then \ + echo "build-arg SHA256 is required" \ + && exit 1; \ + fi \ + && archive="/tmp/factorio_headless_x64_$VERSION.tar.xz" \ + && mkdir -p /opt /factorio \ + && curl -sSL "https://www.factorio.com/get-download/$VERSION/headless/linux64" -o "$archive" --retry $CURL_RETRIES \ + && echo "$SHA256 $archive" | sha256sum -c \ + || (sha256sum "$archive" && file "$archive" && exit 1) \ + && tar xf "$archive" --directory /opt \ + && chmod ugo=rwx /opt/factorio \ + && rm "$archive" \ + && ln -s "$SCENARIOS" /opt/factorio/scenarios \ + && ln -s "$SAVES" /opt/factorio/saves \ + && mkdir -p /opt/factorio/config/ + +COPY files/*.sh / +COPY files/docker-entrypoint-rootless.sh /docker-entrypoint.sh +COPY files/config.ini /opt/factorio/config/config.ini +COPY --from=rcon-builder /src/rcon /bin/rcon + +# Make all scripts executable and set proper permissions for the factorio directory +RUN chmod +x /*.sh \ + && chmod -R 777 /opt/factorio /factorio + +VOLUME /factorio +EXPOSE $PORT/udp $RCON_PORT/tcp + +# Run as non-root user (UID 1000 is common for the first user in rootless containers) +USER 1000:1000 + +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker/files/docker-entrypoint-rootless.sh b/docker/files/docker-entrypoint-rootless.sh new file mode 100755 index 0000000..129dc4f --- /dev/null +++ b/docker/files/docker-entrypoint-rootless.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -eoux pipefail +INSTALLED_DIRECTORY=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") +FACTORIO_VOL=/factorio +LOAD_LATEST_SAVE="${LOAD_LATEST_SAVE:-true}" +GENERATE_NEW_SAVE="${GENERATE_NEW_SAVE:-false}" +PRESET="${PRESET:-""}" +SAVE_NAME="${SAVE_NAME:-""}" +BIND="${BIND:-""}" +CONSOLE_LOG_LOCATION="${CONSOLE_LOG_LOCATION:-""}" + +# Create directories if they don't exist +# In rootless mode, these should be writable by the container user +mkdir -p "$FACTORIO_VOL" +mkdir -p "$SAVES" +mkdir -p "$CONFIG" +mkdir -p "$MODS" +mkdir -p "$SCENARIOS" +mkdir -p "$SCRIPTOUTPUT" + +# Generate RCON password if needed +if [[ ! -f $CONFIG/rconpw ]]; then + pwgen 15 1 >"$CONFIG/rconpw" +fi + +# Copy default configs if they don't exist +if [[ ! -f $CONFIG/server-settings.json ]]; then + cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json" +fi + +if [[ ! -f $CONFIG/map-gen-settings.json ]]; then + cp /opt/factorio/data/map-gen-settings.example.json "$CONFIG/map-gen-settings.json" +fi + +if [[ ! -f $CONFIG/map-settings.json ]]; then + cp /opt/factorio/data/map-settings.example.json "$CONFIG/map-settings.json" +fi + +# Clean up incomplete saves +NRTMPSAVES=$( find -L "$SAVES" -iname \*.tmp.zip -mindepth 1 | wc -l ) +if [[ $NRTMPSAVES -gt 0 ]]; then + rm -f "$SAVES"/*.tmp.zip +fi + +# Update mods if requested +if [[ ${UPDATE_MODS_ON_START:-} == "true" ]]; then + "${INSTALLED_DIRECTORY}"/docker-update-mods.sh +fi + +# Handle DLC +"${INSTALLED_DIRECTORY}"/docker-dlc.sh + +# In rootless mode, we don't need to handle user switching or chown +# The container runs as the specified user from the start +EXEC="" +if [[ -f /bin/box64 ]]; then + # Use emulator for ARM hosts + EXEC="/bin/box64" +fi + +# Update config path +sed -i '/write-data=/c\write-data=\/factorio/' /opt/factorio/config/config.ini + +# Generate new save if needed +NRSAVES=$(find -L "$SAVES" -iname \*.zip -mindepth 1 | wc -l) +if [[ $GENERATE_NEW_SAVE != true && $NRSAVES == 0 ]]; then + GENERATE_NEW_SAVE=true + SAVE_NAME=_autosave1 +fi + +if [[ $GENERATE_NEW_SAVE == true ]]; then + if [[ -z "$SAVE_NAME" ]]; then + echo "If \$GENERATE_NEW_SAVE is true, you must specify \$SAVE_NAME" + exit 1 + fi + if [[ -f "$SAVES/$SAVE_NAME.zip" ]]; then + echo "Map $SAVES/$SAVE_NAME.zip already exists, skipping map generation" + else + if [[ -n "$PRESET" ]]; then + $EXEC /opt/factorio/bin/x64/factorio \ + --create "$SAVES/$SAVE_NAME.zip" \ + --preset "$PRESET" \ + --map-gen-settings "$CONFIG/map-gen-settings.json" \ + --map-settings "$CONFIG/map-settings.json" + else + $EXEC /opt/factorio/bin/x64/factorio \ + --create "$SAVES/$SAVE_NAME.zip" \ + --map-gen-settings "$CONFIG/map-gen-settings.json" \ + --map-settings "$CONFIG/map-settings.json" + fi + fi +fi + +# Build command flags +FLAGS=(\ + --port "$PORT" \ + --server-settings "$CONFIG/server-settings.json" \ + --server-banlist "$CONFIG/server-banlist.json" \ + --rcon-port "$RCON_PORT" \ + --server-whitelist "$CONFIG/server-whitelist.json" \ + --use-server-whitelist \ + --server-adminlist "$CONFIG/server-adminlist.json" \ + --rcon-password "$(cat "$CONFIG/rconpw")" \ + --server-id /factorio/config/server-id.json \ + --mod-directory "$MODS" \ +) + +if [ -n "$CONSOLE_LOG_LOCATION" ]; then + FLAGS+=( --console-log "$CONSOLE_LOG_LOCATION" ) +fi + +if [ -n "$BIND" ]; then + FLAGS+=( --bind "$BIND" ) +fi + +if [[ $LOAD_LATEST_SAVE == true ]]; then + FLAGS+=( --start-server-load-latest ) +else + FLAGS+=( --start-server "$SAVE_NAME" ) +fi + +# Execute factorio +# In rootless mode, we run directly without user switching +exec $EXEC /opt/factorio/bin/x64/factorio "${FLAGS[@]}" "$@" \ No newline at end of file