From 448ee7f8be8ca01ff76bfaceaee1a59cb242e9f6 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:04:30 -0400 Subject: [PATCH 1/3] feat: Find Docker via the current context and add --docker-timeout When DOCKER_HOST isn't set, use the socket of the Docker CLI's current context (DOCKER_CONTEXT, else currentContext in the Docker config), so Docker Desktop on macOS, Colima, OrbStack and similar work without setting DOCKER_HOST. Only unix:// and npipe:// context hosts are used; the default context and tcp:// or ssh:// contexts keep docker-py's from_env() behavior. The host is passed to from_env() through its environment argument instead of changing os.environ. Add --docker-timeout SECONDS (falling back to DOCKER_CLIENT_TIMEOUT) for the Docker API timeout, with a default of 120 seconds instead of docker-py's 60, plus a matching docker-timeout action input. Fixes #42. Fixes #60. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- action.yml | 6 ++ main.py | 77 ++++++++++++++++++++++++-- test_main.py | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b2ddd03..4c01b3c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ uv tool install -e . ## Requirements - Python 3.13+, or `uv` -- Docker +- Docker. `with-connect` uses `DOCKER_HOST` if it is set, otherwise the socket of the current Docker context (`docker context ls`), so Docker Desktop, Colima, and OrbStack work without extra setup. - A valid Posit Connect license file ## Usage @@ -69,6 +69,7 @@ Without `bash -c`, the environment variables would be evaluated before `with-con | `--config` | | Path to optional rstudio-connect.gcfg configuration file | | `--port` | `3939` | Port to map the Connect container to. Allows running multiple Connect instances simultaneously. | | `-e`, `--env` | | Environment variables to pass to the Docker container (format: KEY=VALUE). Can be specified multiple times. | +| `--docker-timeout` | `120` | Seconds to wait for each Docker API call, such as creating the container. Falls back to the `DOCKER_CLIENT_TIMEOUT` environment variable. | | `--stop` | | Stop a running Connect container by ID, or use `CONTAINER_ID` env var if not specified. | Example: @@ -132,6 +133,7 @@ The GitHub Action supports the following inputs: | `config-file` | No | | Path to rstudio-connect.gcfg configuration file | | `port` | No | `3939` | Port to map the Connect container to | | `quiet` | No | `false` | Suppress image pull progress and the Connect logs shown while Connect starts | +| `docker-timeout` | No | `120` | Seconds to wait for each Docker API call, such as creating the container | | `env` | No | | Environment variables to pass to Docker container (one per line, format: KEY=VALUE) | | `command` | No | | Command to run against Connect (omit for start-only mode) | | `stop` | No | | Container ID to stop (use instead of starting a new container) | diff --git a/action.yml b/action.yml index 0152a14..8ad099f 100644 --- a/action.yml +++ b/action.yml @@ -22,6 +22,9 @@ inputs: description: 'Port to map the Connect container to' required: false default: '3939' + docker-timeout: + description: 'Seconds to wait for each Docker API call, such as creating the container (default: 120)' + required: false env: description: 'Environment variables to pass to Docker container (one per line, format: KEY=VALUE)' required: false @@ -75,6 +78,9 @@ runs: if [ -n "${{ inputs.image }}" ]; then ARGS="$ARGS --image ${{ inputs.image }}" fi + if [ -n "${{ inputs.docker-timeout }}" ]; then + ARGS="$ARGS --docker-timeout ${{ inputs.docker-timeout }}" + fi if [ "${{ inputs.quiet }}" = "true" ]; then ARGS="$ARGS --quiet" fi diff --git a/main.py b/main.py index 6656cbc..4ff4fd1 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,11 @@ # Matches the Connect server's command line, for pkill -f CONNECT_PROCESS_PATTERN = "^/opt/rstudio-connect/bin/connect( |$)" +# Seconds to wait for each Docker API call. docker-py's default of 60 is +# too short for container create on a busy machine. +DOCKER_TIMEOUT = 120 +DOCKER_TIMEOUT_ENV = "DOCKER_CLIENT_TIMEOUT" + DESCRIPTION = """\ Starts Posit Connect in Docker and waits until it is ready. @@ -60,6 +65,17 @@ def port_number(value: str) -> int: return port +def timeout_seconds(value: str) -> float: + """Parse --docker-timeout: a positive number of seconds.""" + try: + seconds = float(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid timeout value: '{value}'") + if not 0 < seconds < float("inf"): + raise argparse.ArgumentTypeError(f"{value} is not a valid timeout. Use a positive number of seconds.") + return seconds + + def parse_args(): """ Parse command line arguments. @@ -107,6 +123,13 @@ def parse_args(): default=3939, help="Port to map the Connect container to (default: 3939)", ) + parser.add_argument( + "--docker-timeout", + type=timeout_seconds, + metavar="SECONDS", + help=f"Seconds to wait for each Docker API call, such as creating the container " + f"(default: ${DOCKER_TIMEOUT_ENV} if set, else {DOCKER_TIMEOUT})", + ) parser.add_argument( "--stop", nargs="?", @@ -129,6 +152,15 @@ def parse_args(): args = parser.parse_args(main_args) args.command = command_args + if args.docker_timeout is None: + env_timeout = os.environ.get(DOCKER_TIMEOUT_ENV) + if env_timeout: + try: + args.docker_timeout = timeout_seconds(env_timeout) + except argparse.ArgumentTypeError as e: + parser.error(f"{DOCKER_TIMEOUT_ENV}: {e}") + else: + args.docker_timeout = DOCKER_TIMEOUT return args @@ -172,14 +204,49 @@ def has_local_image(client, image_name: str) -> bool: return False -def docker_client(): +def context_docker_host() -> str | None: + """ + Find the Docker socket from the Docker CLI's current context. + + docker-py only reads DOCKER_HOST and otherwise assumes + /var/run/docker.sock, but Docker Desktop, Colima, OrbStack and others + put their socket elsewhere and point a Docker context at it. Use the + context the docker command would use (DOCKER_CONTEXT, else + currentContext in the Docker config) when DOCKER_HOST isn't set. + + Only local sockets (unix:// and npipe://) are returned. tcp:// and + ssh:// contexts need TLS or SSH settings, so they fall back to + docker-py's defaults. Returns None to use those defaults. + """ + if os.environ.get("DOCKER_HOST"): + return None + try: + context = docker.ContextAPI.get_context(os.environ.get("DOCKER_CONTEXT") or None) + except Exception: + return None # An unreadable context shouldn't stop the default socket from working + host = context.Host if context else None + if not host or not host.startswith(("unix://", "npipe://")): + return None + if host == docker.ContextAPI.DEFAULT_CONTEXT.Host: + return None + return host + + +def docker_client(timeout: float = DOCKER_TIMEOUT): """ Connect to the Docker daemon, with a short error if it isn't reachable. + + :param timeout: Seconds to wait for each Docker API call. """ + environment = dict(os.environ) + host = context_docker_host() + if host: + environment["DOCKER_HOST"] = host try: - return docker.from_env() + return docker.from_env(timeout=timeout, environment=environment) except docker.errors.DockerException as e: - raise RuntimeError(f"Could not connect to Docker. Is it running? {e}") + where = f" at {host} (from the current Docker context)" if host else "" + raise RuntimeError(f"Could not connect to Docker{where}. Is it running? {e}") def _force_amd64(base_image: str) -> bool: @@ -387,7 +454,7 @@ def run() -> int: container_id = args.stop or os.environ.get("CONTAINER_ID") if not container_id: raise RuntimeError("No container ID provided and CONTAINER_ID environment variable not set") - client = docker_client() + client = docker_client(args.docker_timeout) try: container = client.containers.get(container_id) stop_connect_container(container) @@ -416,7 +483,7 @@ def run() -> int: if args.image and args.version != VERSION: raise RuntimeError("Cannot specify both 'image' and 'version'") - client = docker_client() + client = docker_client(args.docker_timeout) if args.image: base_image, tag, used_default = parse_image_spec(args.image) diff --git a/test_main.py b/test_main.py index b998f83..cc25412 100644 --- a/test_main.py +++ b/test_main.py @@ -826,6 +826,137 @@ def test_docker_unreachable_is_reported(): assert str(error) in str(e) +def write_docker_context(config_dir, name, host, current=True): + """Write a Docker CLI context into a temp DOCKER_CONFIG directory.""" + if current: + with open(os.path.join(config_dir, "config.json"), "w") as f: + json.dump({"currentContext": name}, f) + meta_dir = os.path.join( + config_dir, "contexts", "meta", hashlib.sha256(name.encode()).hexdigest() + ) + os.makedirs(meta_dir) + meta = {"Name": name, "Metadata": {}, "Endpoints": {"docker": {"Host": host}}} + with open(os.path.join(meta_dir, "meta.json"), "w") as f: + json.dump(meta, f) + + +def docker_env(config_dir, **env): + """Patch os.environ with only DOCKER_CONFIG and the given Docker variables.""" + base = {k: v for k, v in os.environ.items() if k not in ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG")} + # docker-py falls back to ~/.docker/config.json, so keep HOME away from the real one + return patch.dict(os.environ, {**base, "HOME": config_dir, "DOCKER_CONFIG": config_dir, **env}, clear=True) + + +def test_context_docker_host_uses_current_context(): + with tempfile.TemporaryDirectory() as config_dir: + write_docker_context(config_dir, "desktop-linux", "unix:///Users/me/.docker/run/docker.sock") + with docker_env(config_dir): + assert main.context_docker_host() == "unix:///Users/me/.docker/run/docker.sock" + + +def test_context_docker_host_respects_docker_context(): + with tempfile.TemporaryDirectory() as config_dir: + write_docker_context(config_dir, "desktop-linux", "unix:///desktop.sock") + write_docker_context(config_dir, "colima", "unix:///colima.sock", current=False) + with docker_env(config_dir, DOCKER_CONTEXT="colima"): + assert main.context_docker_host() == "unix:///colima.sock" + with docker_env(config_dir, DOCKER_CONTEXT="default"): + assert main.context_docker_host() is None + + +def test_context_docker_host_defers_to_docker_host(): + with tempfile.TemporaryDirectory() as config_dir: + write_docker_context(config_dir, "desktop-linux", "unix:///desktop.sock") + with docker_env(config_dir, DOCKER_HOST="tcp://example.com:2376"): + assert main.context_docker_host() is None + + +def test_context_docker_host_falls_back_to_default(): + hosts = ["unix:///var/run/docker.sock", "tcp://example.com:2376", "ssh://me@example.com"] + for host in hosts: + with tempfile.TemporaryDirectory() as config_dir: + write_docker_context(config_dir, "other", host) + with docker_env(config_dir): + assert main.context_docker_host() is None, host + # No config file, a missing context, and a corrupt context all use the default + with tempfile.TemporaryDirectory() as config_dir: + with docker_env(config_dir): + assert main.context_docker_host() is None + with open(os.path.join(config_dir, "config.json"), "w") as f: + json.dump({"currentContext": "missing"}, f) + with docker_env(config_dir): + assert main.context_docker_host() is None + meta_dir = os.path.join(config_dir, "contexts", "meta", hashlib.sha256(b"missing").hexdigest()) + os.makedirs(meta_dir) + with open(os.path.join(meta_dir, "meta.json"), "w") as f: + f.write("not json") + with docker_env(config_dir): + assert main.context_docker_host() is None + + +def test_docker_client_uses_context_host_and_timeout(): + docker_host = os.environ.get("DOCKER_HOST") + with ( + patch.object(main, "context_docker_host", return_value="unix:///desktop.sock"), + patch.object(main.docker, "from_env") as from_env, + ): + main.docker_client(timeout=300) + assert from_env.call_args.kwargs["timeout"] == 300 + assert from_env.call_args.kwargs["environment"]["DOCKER_HOST"] == "unix:///desktop.sock" + # The context host is passed to docker-py, not set in this process + assert os.environ.get("DOCKER_HOST") == docker_host + + with ( + patch.object(main, "context_docker_host", return_value=None), + patch.object(main.docker, "from_env") as from_env, + patch.dict(os.environ, {"DOCKER_HOST": "tcp://example.com:2376"}), + ): + main.docker_client() + assert from_env.call_args.kwargs["timeout"] == main.DOCKER_TIMEOUT + assert from_env.call_args.kwargs["environment"]["DOCKER_HOST"] == "tcp://example.com:2376" + + # A context socket that isn't reachable is named in the error + with ( + patch.object(main, "context_docker_host", return_value="unix:///desktop.sock"), + patch.object(main.docker, "from_env", side_effect=docker.errors.DockerException("no socket")), + ): + try: + main.docker_client() + assert False, "Expected RuntimeError" + except RuntimeError as e: + assert "Could not connect to Docker at unix:///desktop.sock" in str(e) + + +def test_docker_timeout_option(): + with patch.dict(os.environ): + os.environ.pop("DOCKER_CLIENT_TIMEOUT", None) + assert parse_with_argv().docker_timeout == main.DOCKER_TIMEOUT + assert parse_with_argv("--docker-timeout", "300").docker_timeout == 300 + assert parse_with_argv("--docker-timeout", "0.5").docker_timeout == 0.5 + + os.environ["DOCKER_CLIENT_TIMEOUT"] = "240" + assert parse_with_argv().docker_timeout == 240 + # The flag wins over the environment variable + assert parse_with_argv("--docker-timeout", "30").docker_timeout == 30 + + +def test_invalid_docker_timeout_is_rejected(): + with patch.dict(os.environ): + os.environ.pop("DOCKER_CLIENT_TIMEOUT", None) + for value in ("0", "-5", "inf", "nan"): + code, output = parse_exit("--docker-timeout", value) + assert code == 2 + assert f"{value} is not a valid timeout" in output + code, output = parse_exit("--docker-timeout", "abc") + assert code == 2 + assert "invalid timeout value: 'abc'" in output + + os.environ["DOCKER_CLIENT_TIMEOUT"] = "soon" + code, output = parse_exit() + assert code == 2 + assert "DOCKER_CLIENT_TIMEOUT: invalid timeout value: 'soon'" in output + + def test_main_prints_short_error(): output = io.StringIO() with patch.object(main, "run", side_effect=RuntimeError("boom")), redirect_stderr(output): @@ -1035,6 +1166,27 @@ def test_installed_command_uses_main(): test_docker_unreachable_is_reported() print("✓ test_docker_unreachable_is_reported passed") + test_context_docker_host_uses_current_context() + print("✓ test_context_docker_host_uses_current_context passed") + + test_context_docker_host_respects_docker_context() + print("✓ test_context_docker_host_respects_docker_context passed") + + test_context_docker_host_defers_to_docker_host() + print("✓ test_context_docker_host_defers_to_docker_host passed") + + test_context_docker_host_falls_back_to_default() + print("✓ test_context_docker_host_falls_back_to_default passed") + + test_docker_client_uses_context_host_and_timeout() + print("✓ test_docker_client_uses_context_host_and_timeout passed") + + test_docker_timeout_option() + print("✓ test_docker_timeout_option passed") + + test_invalid_docker_timeout_is_rejected() + print("✓ test_invalid_docker_timeout_is_rejected passed") + test_main_prints_short_error() print("✓ test_main_prints_short_error passed") From b2ff563d7216739216d66fadbb6739698724f0f4 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:07:31 -0400 Subject: [PATCH 2/3] fix: Pass docker-timeout to the action's stop mode too Co-Authored-By: Claude Opus 5.5 --- action.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index 8ad099f..342e95f 100644 --- a/action.yml +++ b/action.yml @@ -64,23 +64,25 @@ runs: id: run shell: bash run: | + DOCKER_ARGS="" + if [ -n "${{ inputs.docker-timeout }}" ]; then + DOCKER_ARGS="--docker-timeout ${{ inputs.docker-timeout }}" + fi + # Handle stop mode if [ -n "${{ inputs.stop }}" ]; then - with-connect --stop "${{ inputs.stop }}" + with-connect $DOCKER_ARGS --stop "${{ inputs.stop }}" exit 0 fi # Build arguments - ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }}" + ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }} $DOCKER_ARGS" if [ -n "${{ inputs.config-file }}" ]; then ARGS="$ARGS --config ${{ inputs.config-file }}" fi if [ -n "${{ inputs.image }}" ]; then ARGS="$ARGS --image ${{ inputs.image }}" fi - if [ -n "${{ inputs.docker-timeout }}" ]; then - ARGS="$ARGS --docker-timeout ${{ inputs.docker-timeout }}" - fi if [ "${{ inputs.quiet }}" = "true" ]; then ARGS="$ARGS --quiet" fi From 6cb29497d8725eba82aa65c20b2cf29b58d5a3a0 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 23 Oct 2026 10:08:42 -0400 Subject: [PATCH 3/3] refactor: Keep the docker-timeout action changes apart from the port lines Co-Authored-By: Claude Opus 5.5 --- action.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index 342e95f..c9e8158 100644 --- a/action.yml +++ b/action.yml @@ -22,9 +22,6 @@ inputs: description: 'Port to map the Connect container to' required: false default: '3939' - docker-timeout: - description: 'Seconds to wait for each Docker API call, such as creating the container (default: 120)' - required: false env: description: 'Environment variables to pass to Docker container (one per line, format: KEY=VALUE)' required: false @@ -34,6 +31,9 @@ inputs: stop: description: 'Container ID to stop (use instead of starting a new container)' required: false + docker-timeout: + description: 'Seconds to wait for each Docker API call, such as creating the container (default: 120)' + required: false outputs: CONNECT_API_KEY: @@ -76,7 +76,7 @@ runs: fi # Build arguments - ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }} $DOCKER_ARGS" + ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }}" if [ -n "${{ inputs.config-file }}" ]; then ARGS="$ARGS --config ${{ inputs.config-file }}" fi @@ -86,6 +86,7 @@ runs: if [ "${{ inputs.quiet }}" = "true" ]; then ARGS="$ARGS --quiet" fi + ARGS="$ARGS $DOCKER_ARGS" if [ -n "${{ inputs.env }}" ]; then while IFS= read -r line; do if [ -n "$line" ]; then