diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1a9e2..4407495 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,60 @@ jobs: [ "$TEST_STRING" = "This contains single quotes" ] || exit 1 echo "✓ Multiline test passed - variables, single quotes, and double quotes all work" + test-action-start-only: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Start Connect (no command) + id: start-connect + uses: ./ + with: + version: 2024.08.0 + license: ${{ secrets.CONNECT_LICENSE }} + + - name: Verify outputs are set + run: | + set -euo pipefail + echo "API Key length: ${#CONNECT_API_KEY}" + echo "Server: $CONNECT_SERVER" + echo "Container ID: $CONTAINER_ID" + [ -n "$CONNECT_API_KEY" ] || { echo "ERROR: CONNECT_API_KEY output is empty"; exit 1; } + [ -n "$CONNECT_SERVER" ] || { echo "ERROR: CONNECT_SERVER output is empty"; exit 1; } + [ -n "$CONTAINER_ID" ] || { echo "ERROR: CONTAINER_ID output is empty"; exit 1; } + echo "✓ All outputs are set" + env: + CONNECT_API_KEY: ${{ steps.start-connect.outputs.CONNECT_API_KEY }} + CONNECT_SERVER: ${{ steps.start-connect.outputs.CONNECT_SERVER }} + CONTAINER_ID: ${{ steps.start-connect.outputs.CONTAINER_ID }} + + - name: Use Connect with outputs + run: | + set -euo pipefail + RESPONSE=$(curl -f -H "Authorization: Key $CONNECT_API_KEY" "$CONNECT_SERVER/__api__/v1/content") + echo "Response: $RESPONSE" + [ "$RESPONSE" = "[]" ] || exit 1 + echo "✓ Successfully used Connect with action outputs" + env: + CONNECT_API_KEY: ${{ steps.start-connect.outputs.CONNECT_API_KEY }} + CONNECT_SERVER: ${{ steps.start-connect.outputs.CONNECT_SERVER }} + + - name: Stop Connect + uses: ./ + with: + stop: ${{ steps.start-connect.outputs.CONTAINER_ID }} + + - name: Verify container stopped + run: | + set -euo pipefail + if docker ps -q --filter "id=$CONTAINER_ID" | grep -q .; then + echo "ERROR: Container is still running" + exit 1 + fi + echo "✓ Container successfully stopped" + env: + CONTAINER_ID: ${{ steps.start-connect.outputs.CONTAINER_ID }} + test-cli: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 8852270..9243311 100644 --- a/README.md +++ b/README.md @@ -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. | +| `--stop` | | Stop a running Connect container by ID, or use `CONTAINER_ID` env var if not specified. | Example: @@ -86,6 +87,30 @@ You can use this to override Connect server configuration by passing in `CONNECT If you need env vars that are useful for the command running after `--`, just set them in the environment from which you call `with-connect`: the command will inherit that environment. +### Start-Only Mode + +If you omit the command after `--`, Connect will start and remain running. The tool outputs shell variables you can use to interact with Connect: + +```bash +with-connect --license ./rstudio-connect.lic +# Outputs: +# CONNECT_API_KEY=... +# CONNECT_SERVER=http://localhost:3939 +# CONTAINER_ID=... +``` + +You can eval the output to set the variables in your shell: + +```bash +eval $(with-connect --license ./rstudio-connect.lic) +curl -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content + +# Stop Connect when done (--stop without argument uses $CONTAINER_ID) +with-connect --stop +``` + +This is useful when you need to run multiple commands or use other tools against the running Connect instance. + ## GitHub Actions This project contains a GitHub Action for use in CI/CD workflows. Use the `@main` tag to reference the action. @@ -105,7 +130,18 @@ The GitHub Action supports the following inputs: | `port` | No | `3939` | Port to map the Connect container to | | `quiet` | No | `false` | Suppress progress indicators during image pull | | `env` | No | | Environment variables to pass to Docker container (one per line, format: KEY=VALUE) | -| `command` | Yes | | Command to run against Connect | +| `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) | + +### GitHub Action Outputs + +When no `command` is provided (start-only mode), the action sets these outputs: + +| Output | Description | +|-------------------|------------------------------------------| +| `CONNECT_API_KEY` | Connect API key for authentication | +| `CONNECT_SERVER` | Connect server URL (e.g., `http://localhost:3939`) | +| `CONTAINER_ID` | Docker container ID (use with `stop` input to stop the container) | ### Deploy a Connect Manifest @@ -184,6 +220,46 @@ The `$CONNECT_API_KEY` and `$CONNECT_SERVER` environment variables are available command: rsconnect deploy manifest . ``` +### Multi-Step Workflows (Start-Only Mode) + +For workflows that need to run multiple steps against Connect, or use other actions with the running instance, use start-only mode: + +```yaml +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # Start Connect without a command - it will keep running + - name: Start Connect + id: connect + uses: posit-dev/with-connect@main + with: + version: 2025.09.0 + license: ${{ secrets.CONNECT_LICENSE_FILE }} + + # Use the outputs in subsequent steps + - name: Deploy content + run: rsconnect deploy manifest . + env: + CONNECT_API_KEY: ${{ steps.connect.outputs.CONNECT_API_KEY }} + CONNECT_SERVER: ${{ steps.connect.outputs.CONNECT_SERVER }} + + # Use another action with Connect + - name: Run integration tests + uses: some-other-action@v1 + with: + connect-url: ${{ steps.connect.outputs.CONNECT_SERVER }} + api-key: ${{ steps.connect.outputs.CONNECT_API_KEY }} + + # Stop Connect when done + - name: Stop Connect + uses: posit-dev/with-connect@main + with: + stop: ${{ steps.connect.outputs.CONTAINER_ID }} +``` + ## Minimum Version Posit Connect 2022.10.0 or later is required. Earlier versions did not have the bootstrap endpoint used in this utility. diff --git a/action.yml b/action.yml index 0cd0ebb..d23f0e1 100644 --- a/action.yml +++ b/action.yml @@ -26,8 +26,22 @@ inputs: description: 'Environment variables to pass to Docker container (one per line, format: KEY=VALUE)' required: false command: - description: 'Command to run against Connect' - required: true + description: 'Command to run against Connect (omit to start Connect and output credentials)' + required: false + stop: + description: 'Container ID to stop (use instead of starting a new container)' + required: false + +outputs: + CONNECT_API_KEY: + description: 'Connect API key (only set when no command is provided)' + value: ${{ steps.run.outputs.CONNECT_API_KEY }} + CONNECT_SERVER: + description: 'Connect server URL (only set when no command is provided)' + value: ${{ steps.run.outputs.CONNECT_SERVER }} + CONTAINER_ID: + description: 'Docker container ID (only set when no command is provided)' + value: ${{ steps.run.outputs.CONTAINER_ID }} runs: using: composite @@ -44,8 +58,16 @@ runs: run: echo "${{ inputs.license }}" > rstudio-connect.lic - name: Run command + id: run shell: bash run: | + # Handle stop mode + if [ -n "${{ inputs.stop }}" ]; then + with-connect --stop "${{ inputs.stop }}" + exit 0 + fi + + # Build arguments ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }}" if [ -n "${{ inputs.config-file }}" ]; then ARGS="$ARGS --config ${{ inputs.config-file }}" @@ -63,6 +85,14 @@ runs: fi done <<< "${{ inputs.env }}" fi - with-connect $ARGS -- bash <<'SCRIPT' + + # Check if command is provided + if [ -z "${{ inputs.command }}" ]; then + # Start-only mode: output is already in KEY=value format + with-connect $ARGS >> $GITHUB_OUTPUT + else + # Run with command + with-connect $ARGS -- bash <<'SCRIPT' ${{ inputs.command }} SCRIPT + fi diff --git a/main.py b/main.py index 4b357b1..54e9b69 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,14 @@ def parse_args(): default=3939, help="Port to map the Connect container to (default: 3939)", ) + parser.add_argument( + "--stop", + nargs="?", + default=None, + const="", # sentinel for --stop without argument + metavar="CONTAINER_ID", + help="Stop a running Connect container by ID (uses CONTAINER_ID env var if not specified)", + ) # Handle -- separator and capture remaining args if "--" in sys.argv: @@ -99,9 +107,9 @@ def pull_image(client, base_image: str, tag: str, quiet: bool) -> None: image_name = f"{base_image}:{tag}" if quiet: - print(f"Pulling image {image_name}...") + print(f"Pulling image {image_name}...", file=sys.stderr) else: - print(f"Pulling image {image_name}...", end="", flush=True) + print(f"Pulling image {image_name}...", end="", flush=True, file=sys.stderr) pull_stream = client.api.pull( base_image, tag=tag, platform="linux/amd64", stream=True, decode=True @@ -112,12 +120,12 @@ def pull_image(client, base_image: str, tag: str, quiet: bool) -> None: if "status" in chunk: dot_count += 1 if dot_count % 10 == 0 and not quiet: - print(".", end="", flush=True) + print(".", end="", flush=True, file=sys.stderr) if not quiet: - print() + print(file=sys.stderr) - print(f"Successfully pulled {image_name}") + print(f"Successfully pulled {image_name}", file=sys.stderr) def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) -> None: @@ -134,14 +142,14 @@ def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) - is_release = version in ("latest", "release", "preview") if not is_release and has_local_image(client, image_name): - print(f"Using locally cached image {image_name}") + print(f"Using locally cached image {image_name}", file=sys.stderr) return try: pull_image(client, base_image, tag, quiet) except Exception as e: if has_local_image(client, image_name): - print(f"Pull failed, but using locally cached image {image_name}") + print(f"Pull failed, but using locally cached image {image_name}", file=sys.stderr) else: raise RuntimeError(f"Failed to pull image and no local copy available: {e}") @@ -216,6 +224,20 @@ def main() -> int: """ args = parse_args() + # Handle --stop mode: just stop the container and exit + if args.stop is not None: + 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.from_env() + try: + container = client.containers.get(container_id) + container.stop() + print(f"Stopped container {container_id}", file=sys.stderr) + return 0 + except docker.errors.NotFound: + raise RuntimeError(f"Container not found: {container_id}") + license_path = os.path.abspath(os.path.expanduser(args.license)) if not os.path.exists(license_path): raise RuntimeError(f"License file does not exist: {license_path}") @@ -233,7 +255,7 @@ def main() -> int: if args.image: base_image, tag, used_default = parse_image_spec(args.image) if used_default: - print(f"No tag specified for image '{args.image}'. Using default tag 'latest'.") + print(f"No tag specified for image '{args.image}'. Using default tag 'latest'.", file=sys.stderr) else: base_image, tag = get_docker_tag(args.version) image_name = f"{base_image}:{tag}" @@ -286,18 +308,19 @@ def main() -> int: ) server_url = f"http://localhost:{args.port}" + stop_container = True try: - print(f"Waiting for port {args.port} to open...") + print(f"Waiting for port {args.port} to open...", file=sys.stderr) if not is_port_open("localhost", args.port, timeout=60.0): - print("\nContainer logs:") - print(container.logs().decode("utf-8", errors="replace")) + print("\nContainer logs:", file=sys.stderr) + print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) raise RuntimeError("Posit Connect did not start within 60 seconds.") - print("Waiting for HTTP server to start...") + print("Waiting for HTTP server to start...", file=sys.stderr) if not wait_for_http_server(container, timeout=60.0, poll_interval=2.0): - print("\nContainer logs:") - print(container.logs().decode("utf-8", errors="replace")) + print("\nContainer logs:", file=sys.stderr) + print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) raise RuntimeError( "Posit Connect did not log HTTP server start within 60 seconds." ) @@ -317,10 +340,17 @@ def main() -> int: exit_code = result.returncode except subprocess.CalledProcessError as e: exit_code = e.returncode + else: + # Start-only mode: output credentials and keep container running + print(f"CONNECT_API_KEY={api_key}") + print(f"CONNECT_SERVER={server_url}") + print(f"CONTAINER_ID={container.id}") + stop_container = False return exit_code finally: - container.stop() + if stop_container: + container.stop() def is_port_open(host: str, port: int, timeout: float = 30.0) -> bool: @@ -374,11 +404,11 @@ def wait_for_http_server( if not version: version = extract_server_version(logs) if version: - print(f"Running Posit Connect v{version}") + print(f"Running Posit Connect v{version}", file=sys.stderr) if "Unable to obtain a valid license" in logs: - print("\nContainer logs:") - print(logs) + print("\nContainer logs:", file=sys.stderr) + print(logs, file=sys.stderr) container.stop() raise RuntimeError( "Unable to obtain a valid license. Your Posit Connect license may be expired or invalid. Please check your license file." @@ -416,17 +446,17 @@ def get_api_key(bootstrap_secret: str, container, server_url: str) -> str: if response and "api_key" in response: api_key = response["api_key"] if not api_key: - print("\nContainer logs:") - print(container.logs().decode("utf-8", errors="replace")) + print("\nContainer logs:", file=sys.stderr) + print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) raise RuntimeError("Bootstrap succeeded but returned empty API key") return api_key else: - print("\nContainer logs:") - print(container.logs().decode("utf-8", errors="replace")) + print("\nContainer logs:", file=sys.stderr) + print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) raise RuntimeError(f"Bootstrap returned unexpected response: {response}") except Exception as e: - print("\nContainer logs:") - print(container.logs().decode("utf-8", errors="replace")) + print("\nContainer logs:", file=sys.stderr) + print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) raise RuntimeError(f"Failed to bootstrap Connect and retrieve API key: {e}") diff --git a/test_main.py b/test_main.py index d77cfaf..4803915 100644 --- a/test_main.py +++ b/test_main.py @@ -226,6 +226,30 @@ def test_image_with_tag(): assert used_default is False +def test_stop_argument_in_help(): + """Test that --stop argument is available.""" + result = subprocess.run( + [sys.executable, "main.py", "--help"], + capture_output=True, + text=True, + ) + + assert "--stop" in result.stdout + assert "CONTAINER_ID" in result.stdout + + +def test_stop_nonexistent_container(): + """Test that --stop with nonexistent container returns error.""" + result = subprocess.run( + [sys.executable, "main.py", "--stop", "nonexistent_container_id"], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "Container not found" in result.stderr + + if __name__ == "__main__": test_license_file_not_exists() print("✓ test_license_file_not_exists passed") @@ -296,4 +320,10 @@ def test_image_with_tag(): test_custom_port() print("✓ test_custom_port passed") + test_stop_argument_in_help() + print("✓ test_stop_argument_in_help passed") + + test_stop_nonexistent_container() + print("✓ test_stop_nonexistent_container passed") + print("\nAll tests passed!")