Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) |
Expand Down
11 changes: 10 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,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:
Expand Down Expand Up @@ -61,9 +64,14 @@ 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

Expand All @@ -78,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
Expand Down
77 changes: 72 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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="?",
Expand All @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
152 changes: 152 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")

Expand Down
Loading