From eeaeb217f7a17d4cafee4d710b6d0c5edc91e05f Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 14:45:13 -0400 Subject: [PATCH 1/9] docs: implementation plan for dropping vetiver test baggage --- ...26-06-30-rsconnect-drop-vetiver-baggage.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md diff --git a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md new file mode 100644 index 00000000..8c598437 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md @@ -0,0 +1,470 @@ +# rsconnect-python: drop vetiver test baggage, adopt with-connect — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove all vetiver-specific test code from rsconnect-python, re-home its own `system caches` integration test onto `posit-dev/with-connect`, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. + +**Architecture:** `with-connect` boots a licensed Connect container and yields an admin API key. rsconnect's only Connect integration test (`test_main_system_caches.py`) needs two privilege levels, so a fixture creates a non-admin publisher user via the admin key at runtime; the cache-manipulation commands target the `with-connect` container by id. The vetiver↔Connect tests move entirely to the vetiver repo. + +**Tech Stack:** Python, pytest, Click, Docker, `uv`/`uvx`, GitHub Actions, `just`. + +## Global Constraints + +- Pin `with-connect` to commit `0783dabdd24e360e985a4588ce1239c3dc31c542` (no release tags exist yet). Verify at execution time with `gh api repos/posit-dev/with-connect/commits/main -q .sha`. +- `with-connect` exposes only `CONNECT_SERVER` + `CONNECT_API_KEY` to a wrapped command; in **start-only** mode (no `command`) the Action also outputs `CONTAINER_ID`. The `system caches` test needs the container id, so it runs in start-only mode. +- The `actions.py` shim (`deploy_python_fastapi` → `deploy_app` → `validate_*`, lines ~281–442) is **kept**. Do not delete it. Do not alter the active `validate_*` in `bundle.py`. +- A valid `rstudio-connect.lic` must be present in the repo root for local runs; CI passes it via the `RSC_LICENSE` secret. +- Keep CI green at every commit: re-home `test_main_system_caches.py` (Tasks 1–3) **before** deleting `vetiver-testing/` (Task 5). + +--- + +### Task 1: Spike — establish the with-connect publisher-user + container-exec mechanism + +This is a discovery task. `test_main_system_caches.py` asserts that an **admin** can list/delete caches and a **non-admin publisher** is denied. `with-connect` only provides the admin key and runs a plain `docker` container (no `docker compose`). This task pins down, against a live `with-connect` Connect, exactly how to (a) create a publisher user + its API key from the admin key, and (b) run the cache-setup commands inside the container. + +**Files:** +- Create (temporary): `scratch_spike.py` (deleted at end of task) + +- [ ] **Step 1: Start Connect in start-only mode and capture the container id + creds** + +Run: +```bash +eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect)" +echo "server=$CONNECT_SERVER container=$(docker ps --filter ancestor --format '{{.ID}}' | head -1)" +docker ps --format '{{.ID}}\t{{.Image}}' | grep -i connect +``` +Expected: `CONNECT_SERVER` and `CONNECT_API_KEY` are exported; one running Connect container is listed. Record its container id. + +- [ ] **Step 2: Confirm cache-dir manipulation works via `docker exec` (not `docker compose exec`)** + +Run (substitute the container id from Step 1 for `$CID`): +```bash +docker exec -u rstudio-connect -T $CID mkdir -p /data/python-environments/_packages_cache/pip/1.2.3 +docker exec -u rstudio-connect -T $CID [ -d /data/python-environments/_packages_cache/pip/1.2.3 ] && echo "CACHE_OK" +``` +Expected: prints `CACHE_OK`. (If `-u rstudio-connect` is rejected, record the correct user; the with-connect image may differ from `rstudio/rstudio-connect`.) + +- [ ] **Step 3: Determine how to create a publisher user + API key from the admin key** + +Write `scratch_spike.py` and try the rsconnect-native client first (no new deps): + +```python +import os +from rsconnect.api import RSConnectServer, RSConnectClient + +server = RSConnectServer(url=os.environ["CONNECT_SERVER"], api_key=os.environ["CONNECT_API_KEY"]) +client = RSConnectClient(server) + +# Probe: what does the admin identity look like, and can we create a user? +print("me:", client.me()) +resp = client._server.handle_bad_response # noqa (inspect available methods) +print([m for m in dir(client) if "user" in m.lower() or "key" in m.lower()]) +``` + +Run: `uvx --with . python scratch_spike.py` +Expected: prints the admin identity and the available user/key methods. + +Decision point — pick the simplest mechanism that works against this Connect: + 1. **rsconnect client / raw REST**: `POST {server}/__api__/v1/users` to create a publisher, then mint a key. Verify the endpoint and key-creation route actually exist on this image. + 2. **Custom gcfg via the Action's `config-file`**: if API-driven user creation is not supported by with-connect's default auth provider, supply a minimal `rstudio-connect.gcfg` (password auth) plus a startup user, mirroring the old `vetiver-testing` setup but passed through `with-connect`'s `config-file` input. + +Record the working approach (exact REST calls or the gcfg + the publisher key retrieval) in the task notes — Task 2 consumes it. + +- [ ] **Step 4: Tear down and clean up** + +Run: +```bash +uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop +rm -f scratch_spike.py +``` +Expected: container stops; scratch file removed. **No commit** (spike produces notes only). + +--- + +### Task 2: Refactor `test_main_system_caches.py` onto env creds + a publisher fixture + +**Files:** +- Modify: `tests/test_main_system_caches.py` + +**Interfaces:** +- Consumes (from env): `CONNECT_SERVER`, `CONNECT_API_KEY` (admin), `CONNECT_CONTAINER` (container id from with-connect start-only mode). +- Consumes (from Task 1): the verified publisher-user creation mechanism. +- Produces: a `publisher_key` fixture; no dependency on `vetiver-testing/rsconnect_api_keys.json` or `docker compose`. + +- [ ] **Step 1: Replace the module header (creds, container ref, docker commands)** + +Replace lines 1–47 (imports through `apply_common_args`) of `tests/test_main_system_caches.py` with: + +```python +import os +import unittest +from os import system + +from click.testing import CliRunner + +from rsconnect.main import cli + +CONNECT_SERVER = os.environ.get("CONNECT_SERVER", "http://localhost:3939") +ADMIN_KEY = os.environ.get("CONNECT_API_KEY") +CONTAINER = os.environ.get("CONNECT_CONTAINER", "") +CONNECT_CACHE_DIR = "/data/python-environments/_packages_cache" + +_EXEC = f"docker exec -u rstudio-connect -T {CONTAINER}" +ADD_CACHE_COMMAND = f"{_EXEC} mkdir -p {CONNECT_CACHE_DIR}/pip/1.2.3" +RM_CACHE_COMMAND = f"{_EXEC} rm -Rf {CONNECT_CACHE_DIR}/pip/1.2.3" +# The following returns int(0) if dir exists, else nonzero. +CACHE_EXISTS_COMMAND = f"{_EXEC} [ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]" + + +def rsconnect_service_running(): + if not CONTAINER: + return False + return system(f"docker inspect -f '{{{{.State.Running}}}}' {CONTAINER}") == 0 + + +def cache_dir_exists(): + return system(CACHE_EXISTS_COMMAND) == 0 + + +def make_publisher_key(): + """Create a non-admin publisher user via the admin key and return its API key. + + Implementation comes from Task 1's verified mechanism. Replace the body below + with the exact calls confirmed in the spike. + """ + from rsconnect.api import RSConnectClient, RSConnectServer # local import + + server = RSConnectServer(url=CONNECT_SERVER, api_key=ADMIN_KEY) + client = RSConnectClient(server) + # <-- insert the verified create-publisher-and-mint-key calls here --> + raise NotImplementedError("fill from Task 1 spike result") + + +def apply_common_args(args: list, server=None, key=None, insecure=True): + if server: + args.extend(["-s", server]) + if key: + args.extend(["-k", key]) + if insecure: + args.extend(["--insecure"]) +``` + +> Note: the `make_publisher_key` body is the single point that depends on the Task 1 spike. Everything else is final. Do not leave `NotImplementedError` in the committed version — Step 2 fills it. + +- [ ] **Step 2: Fill `make_publisher_key` with the verified mechanism and add a module-level publisher key** + +Using the approach confirmed in Task 1, implement `make_publisher_key()` so it returns a usable publisher API key, then add below `apply_common_args`: + +```python +PUBLISHER_KEY = make_publisher_key() if ADMIN_KEY else None +``` + +Replace every `get_key("admin")` with `ADMIN_KEY` and every `get_key("susan")` with `PUBLISHER_KEY` in the test methods (lines ~67, 82, 109, 122, 137). Delete the old `get_key`, `CONNECT_KEYS_JSON`, and the `SERVICE_RUNNING_COMMAND` constant. + +- [ ] **Step 3: Verify the refactored test passes under with-connect start-only mode** + +Run: +```bash +eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect)" +export CONNECT_CONTAINER="$(docker ps --format '{{.ID}}' --filter status=running | head -1)" +uv run --no-sync pytest tests/test_main_system_caches.py -v +uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop +``` +Expected: PASS — admin list/delete succeed (exit 0), publisher list/delete are denied (exit 1, "You don't have permission to perform this operation."). + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_main_system_caches.py +git commit -m "test: run system-caches integration test via with-connect, create publisher at runtime" +``` + +--- + +### Task 3: Replace the `test-dev-connect` CI job with an rsconnect-own with-connect job + +**Files:** +- Modify: `.github/workflows/main.yml` (the `test-dev-connect` job, lines ~183–211) + +**Interfaces:** +- Consumes: `secrets.RSC_LICENSE`; the refactored `test_main_system_caches.py` from Task 2. +- Produces: a job that runs only rsconnect's own integration test, with no vetiver install and no `docker compose`/`just dev`. + +- [ ] **Step 1: Replace the entire `test-dev-connect` job** + +Replace the job (from ` test-dev-connect:` through the final `uv run --no-sync pytest --vetiver -m 'vetiver'` line) with: + +```yaml + test-system-caches: + name: "Integration tests against dev Connect" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v6 + with: + version: ">=0.9.0" + - name: Install dependencies + run: uv sync --python 3.12 --group test + - name: Start Connect + id: connect + uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 + with: + license: ${{ secrets.RSC_LICENSE }} + - name: Run system caches tests + run: uv run --no-sync pytest tests/test_main_system_caches.py + env: + CONNECT_SERVER: ${{ steps.connect.outputs.CONNECT_SERVER }} + CONNECT_API_KEY: ${{ steps.connect.outputs.CONNECT_API_KEY }} + CONNECT_CONTAINER: ${{ steps.connect.outputs.CONTAINER_ID }} + - name: Stop Connect + if: always() + uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 + with: + license: ${{ secrets.RSC_LICENSE }} + stop: ${{ steps.connect.outputs.CONTAINER_ID }} +``` + +> If the spike (Task 1) determined a custom gcfg is required to support publisher-user creation, add `config-file: ` to the `Start Connect` step and commit that gcfg alongside the test. + +- [ ] **Step 2: Lint the workflow YAML** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/main.yml'))"` +Expected: no output (valid YAML). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/main.yml +git commit -m "ci: run system-caches integration test via with-connect; drop vetiver job" +``` + +--- + +### Task 4: Remove the `--vetiver` marker plumbing and the vetiver test + +**Files:** +- Modify: `conftest.py` +- Delete: `tests/test_vetiver_pins.py` +- Modify: `pyproject.toml` (markers + ruff exclude) + +- [ ] **Step 1: Remove the vetiver option/marker logic from `conftest.py`** + +Delete these three functions from `conftest.py`: + +```python +def pytest_addoption(parser): + parser.addoption("--vetiver", action="store_true", default=False, help="run vetiver tests") + + +def pytest_configure(config): + config.addinivalue_line("markers", "vetiver: test for vetiver interaction") + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--vetiver"): + return + skip_vetiver = pytest.mark.skip(reason="need --vetiver option to run") + for item in items: + if "vetiver" in item.keywords: + item.add_marker(skip_vetiver) +``` + +The remaining `conftest.py` keeps the `CONNECT_CONTENT_BUILD_DIR` setup. The `import pytest` line is now unused — remove it too. + +- [ ] **Step 2: Delete the vetiver integration test** + +Run: `git rm tests/test_vetiver_pins.py` + +- [ ] **Step 3: Remove the `vetiver` marker and the `vetiver-testing` ruff exclude from `pyproject.toml`** + +Change line ~74: + +```toml +markers = ["vetiver: tests for vetiver"] +``` + +If `markers` is the only key that becomes empty, replace with an empty list: + +```toml +markers = [] +``` + +And in the ruff `extend-exclude` (line ~63), drop `"vetiver-testing"`: + +```toml +extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "tests/testdata"] +``` + +- [ ] **Step 4: Verify collection has no orphaned marker references** + +Run: `uv run pytest --collect-only -q 2>&1 | tail -5` +Expected: collection succeeds with no `PytestUnknownMarkWarning: vetiver` and no error about a missing `--vetiver` option. + +- [ ] **Step 5: Commit** + +```bash +git add conftest.py pyproject.toml +git commit -m "test: drop --vetiver marker plumbing and test_vetiver_pins" +``` + +--- + +### Task 5: Delete the bespoke harness (vetiver-testing, docker-compose, Justfile dev recipes) + +**Files:** +- Delete: `vetiver-testing/` (entire directory) +- Delete: `docker-compose.yml` (root) +- Modify: `Justfile` (remove `dev` and `dev-stop` recipes) + +- [ ] **Step 1: Confirm nothing still references these paths** + +Run: +```bash +grep -rn "vetiver-testing\|rsconnect_api_keys\|docker compose\|docker-compose" \ + --include='*.py' --include='*.yml' --include='*.yaml' Justfile conftest.py . \ + | grep -v 'docs/superpowers' | grep -v 'integration-testing/' +``` +Expected: no hits outside `docs/superpowers/` and the unrelated `integration-testing/` tree. + +- [ ] **Step 2: Remove the `dev` and `dev-stop` recipes from `Justfile`** + +Delete these two recipes: + +```just +# Start a local Connect server for development (Docker; not replaced by uv) +dev: + docker compose up -d + sleep 30 + docker compose exec -T rsconnect bash < vetiver-testing/setup-rsconnect/add-users.sh + uv run python vetiver-testing/setup-rsconnect/dump_api_keys.py vetiver-testing/rsconnect_api_keys.json + +# Stop the local Connect server +dev-stop: + docker compose down + rm -f vetiver-testing/rsconnect_api_keys.json +``` + +- [ ] **Step 3: Delete the directories** + +Run: +```bash +git rm -r vetiver-testing +git rm docker-compose.yml +``` + +- [ ] **Step 4: Verify the unit suite and `just` recipes are intact** + +Run: `just --list` +Expected: lists recipes with no `dev`/`dev-stop`, no parse error. + +Run: `uv run pytest -q -k "not system_caches"` +Expected: PASS (the offline unit suite is unaffected). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore: remove vetiver test harness and root docker-compose" +``` + +--- + +### Task 6: Relabel the `actions.py` compatibility shim + +**Files:** +- Modify: `rsconnect/actions.py` (comment block markers at lines ~281–285 and ~441–442; optional `validate_*` swap) + +**Interfaces:** +- Produces: no behavior change for callers; `deploy_python_fastapi`/`deploy_app` remain importable with identical signatures. + +- [ ] **Step 1: Rewrite the opening block comment** + +Replace lines ~281–285: + +```python +# START: The following deprecated functions are here only for the vetiver-python +# package. +# Some the code in this section has `pyright: ignore` comments, because this +# deprecated code which will be removed in the future. +# =============================================================================== +``` + +with: + +```python +# START: Compatibility entry point used by the vetiver-python package. +# vetiver's `deploy_connect` calls `deploy_python_fastapi` (below), which routes +# through `deploy_app` and the local `validate_*` helpers. This is a supported +# shim; keep these signatures stable. The `pyright: ignore` comments remain +# because the kwargs-forwarding style predates strict typing. +# =============================================================================== +``` + +- [ ] **Step 2: Rewrite the closing block comment** + +Replace lines ~441–442: + +```python +# =============================================================================== +# END deprecated functions for the vetiver-python package +# =============================================================================== +``` + +with: + +```python +# =============================================================================== +# END compatibility entry point for the vetiver-python package +# =============================================================================== +``` + +- [ ] **Step 3 (optional cleanup): Stop emitting spurious deprecation warnings on vetiver deploys** + +`deploy_app` currently calls the local `validate_entry_point`/`validate_extra_files`, which each emit a `DeprecationWarning`. Point it at the active `bundle.py` versions instead. At the top of `actions.py`, confirm/add the import: + +```python +from .bundle import validate_entry_point as _validate_entry_point +from .bundle import validate_extra_files as _validate_extra_files +``` + +Then in `deploy_app` change lines ~361–362: + +```python + kwargs["entry_point"] = entry_point = validate_entry_point(entry_point, directory) # pyright: ignore + kwargs["extra_files"] = extra_files = validate_extra_files(directory, extra_files) # pyright: ignore +``` + +to: + +```python + kwargs["entry_point"] = entry_point = _validate_entry_point(entry_point, directory) # pyright: ignore + kwargs["extra_files"] = extra_files = _validate_extra_files(directory, extra_files) # pyright: ignore +``` + +Confirm the `bundle.py` signatures match: `validate_entry_point(entry_point, directory)` and `validate_extra_files(directory, extra_files)`. If the `bundle.py` `validate_extra_files` requires the extra `use_abspath` argument, pass its default explicitly. + +- [ ] **Step 4: Verify the shim still imports and lints** + +Run: +```bash +uv run python -c "from rsconnect.actions import deploy_python_fastapi, deploy_app; print('ok')" +just lint +``` +Expected: prints `ok`; `just lint` passes (ruff format check + ruff check). + +- [ ] **Step 5: Commit** + +```bash +git add rsconnect/actions.py +git commit -m "docs: relabel deploy_python_fastapi as supported vetiver compat shim" +``` + +--- + +## Self-Review notes + +- Spec "rsconnect-python changes" → Task 1 (spike) + Task 2 (re-home test) + Task 3 (own CI job) cover keeping `test_main_system_caches.py`; Task 4–5 cover all deletions; Task 6 covers the relabel. +- The one genuinely uncertain piece (creating a publisher user / exec-ing the container under `with-connect`) is isolated to Task 1 and the `make_publisher_key` body in Task 2; everything else is concrete. +- Ordering keeps CI green: the system-caches test is re-homed (Tasks 2–3) before `vetiver-testing/` is deleted (Task 5). +- The `actions.py` shim is relabeled, never removed (Task 6); `bundle.py` validate functions are untouched except as an explicit import in the optional cleanup. +- `with-connect` SHA `0783dabdd24e360e985a4588ce1239c3dc31c542` is used identically across Tasks 1–3. From 3125bc70187a8cba98e9cdf521c30066a45fe205 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 16:35:30 -0400 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20revise=20rsconnect=20plan=20?= =?UTF-8?q?=E2=80=94=20gcfg+helper=20publisher=20bootstrap,=20confirmed=20?= =?UTF-8?q?with-connect=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-06-30-rsconnect-drop-vetiver-baggage.md | 204 +++++++++--------- 1 file changed, 106 insertions(+), 98 deletions(-) diff --git a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md index 8c598437..78977f61 100644 --- a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md +++ b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md @@ -2,98 +2,115 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Remove all vetiver-specific test code from rsconnect-python, re-home its own `system caches` integration test onto `posit-dev/with-connect`, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. +**Goal:** Remove all *vetiver-specific* test code from rsconnect-python, re-home its own `system caches` integration test onto `posit-dev/with-connect`, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. -**Architecture:** `with-connect` boots a licensed Connect container and yields an admin API key. rsconnect's only Connect integration test (`test_main_system_caches.py`) needs two privilege levels, so a fixture creates a non-admin publisher user via the admin key at runtime; the cache-manipulation commands target the `with-connect` container by id. The vetiver↔Connect tests move entirely to the vetiver repo. +**Architecture:** `with-connect` boots a licensed Connect container and (in start-only mode) yields an admin API key + the container id. rsconnect's only Connect integration test (`test_main_system_caches.py`) asserts an admin can list/delete caches and a **non-admin publisher is denied (403)**, so it genuinely needs two privilege levels. Since Connect has no public "admin mints another user's key" endpoint, we keep a **minimal** Connect test bootstrap — a password-auth `gcfg` passed via the Action's `config-file` input, a `useradd` of one publisher inside the container, and a small helper that mints that publisher's key via Connect's signup/session flow. This replaces the old `docker-compose` + multi-user `vetiver-testing/` harness with `with-connect` + a slim, de-vetivered config. The vetiver↔Connect tests move entirely to the vetiver repo. -**Tech Stack:** Python, pytest, Click, Docker, `uv`/`uvx`, GitHub Actions, `just`. +**Tech Stack:** Python, pytest, Click, Docker, `uv`, GitHub Actions, `just`. ## Global Constraints - Pin `with-connect` to commit `0783dabdd24e360e985a4588ce1239c3dc31c542` (no release tags exist yet). Verify at execution time with `gh api repos/posit-dev/with-connect/commits/main -q .sha`. -- `with-connect` exposes only `CONNECT_SERVER` + `CONNECT_API_KEY` to a wrapped command; in **start-only** mode (no `command`) the Action also outputs `CONTAINER_ID`. The `system caches` test needs the container id, so it runs in start-only mode. +- **Confirmed `with-connect` Action API** (verified against `action.yml@main`): inputs include `license`, `version`, `config-file`, `env`, `command`, `stop`; in **start-only** mode (no `command`) it sets outputs `CONNECT_SERVER`, `CONNECT_API_KEY`, `CONTAINER_ID`. The `system caches` job uses start-only mode and runs `pytest` as a normal `uv run` step (so the "`pytest` not found inside a wrapped `with-connect -- pytest`" gotcha does not apply here). +- The container runs via plain `docker` (no `docker compose`). Cache-dir setup uses `docker exec ...`, not `docker compose exec`. - The `actions.py` shim (`deploy_python_fastapi` → `deploy_app` → `validate_*`, lines ~281–442) is **kept**. Do not delete it. Do not alter the active `validate_*` in `bundle.py`. +- `pins` is **not** a dependency of rsconnect-python. The old key-mint used `pins.rsconnect.api._HackyConnect`. The publisher-key helper here should prefer a small self-contained raw-HTTP reproduction; only if that proves too fiddly, add `pins` as a **test-only** dependency (Task 1 decides). - A valid `rstudio-connect.lic` must be present in the repo root for local runs; CI passes it via the `RSC_LICENSE` secret. -- Keep CI green at every commit: re-home `test_main_system_caches.py` (Tasks 1–3) **before** deleting `vetiver-testing/` (Task 5). +- Keep CI green at every commit: re-home `test_main_system_caches.py` and stand up its new CI job (Tasks 1–3) **before** deleting `vetiver-testing/` (Task 5). --- -### Task 1: Spike — establish the with-connect publisher-user + container-exec mechanism +### Task 1: Spike — gcfg + JWT-bootstrap coexistence and publisher-key minting -This is a discovery task. `test_main_system_caches.py` asserts that an **admin** can list/delete caches and a **non-admin publisher** is denied. `with-connect` only provides the admin key and runs a plain `docker` container (no `docker compose`). This task pins down, against a live `with-connect` Connect, exactly how to (a) create a publisher user + its API key from the admin key, and (b) run the cache-setup commands inside the container. +Discovery task (no production commit). Resolve, against a live `with-connect` Connect, the exact mechanism the dependent tasks consume. Record findings in the task notes; Tasks 2–3 are written against them. **Files:** -- Create (temporary): `scratch_spike.py` (deleted at end of task) +- Create (temporary, for the spike only): `scratch/` artifacts you delete at the end. A candidate `tests/connect/rstudio-connect.gcfg` you iterate on (kept if it works — see Task 2). -- [ ] **Step 1: Start Connect in start-only mode and capture the container id + creds** +Base the candidate gcfg on the old `vetiver-testing/setup-rsconnect/rstudio-connect.gcfg` but **drop the `[Python]` section** (its image-specific executable paths won't match the with-connect image, and the system-caches test runs no Python content). Keep PAM auth and `DefaultUserRole = publisher`: -Run: -```bash -eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect)" -echo "server=$CONNECT_SERVER container=$(docker ps --filter ancestor --format '{{.ID}}' | head -1)" -docker ps --format '{{.ID}}\t{{.Image}}' | grep -i connect +```ini +[Server] +DataDir = /data +Address = http://localhost:3939 + +[HTTP] +Listen = :3939 + +[Authentication] +Provider = pam + +[Authorization] +DefaultUserRole = publisher + +[Logging] +ServiceLog = STDOUT ``` -Expected: `CONNECT_SERVER` and `CONNECT_API_KEY` are exported; one running Connect container is listed. Record its container id. -- [ ] **Step 2: Confirm cache-dir manipulation works via `docker exec` (not `docker compose exec`)** +- [ ] **Step 1: Confirm with-connect's JWT bootstrap coexists with the PAM gcfg** -Run (substitute the container id from Step 1 for `$CID`): +Start Connect with the candidate config (start-only). Use the CLI's `--config` (the CLI equivalent of the Action's `config-file`): ```bash -docker exec -u rstudio-connect -T $CID mkdir -p /data/python-environments/_packages_cache/pip/1.2.3 -docker exec -u rstudio-connect -T $CID [ -d /data/python-environments/_packages_cache/pip/1.2.3 ] && echo "CACHE_OK" +eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --config tests/connect/rstudio-connect.gcfg)" +echo "server=$CONNECT_SERVER key set=${CONNECT_API_KEY:+yes}" +CID=$(docker ps --format '{{.ID}}' --filter status=running | head -1); echo "container=$CID" +curl -fsS -H "Authorization: Key $CONNECT_API_KEY" "$CONNECT_SERVER/__api__/v1/user" && echo OK ``` -Expected: prints `CACHE_OK`. (If `-u rstudio-connect` is rejected, record the correct user; the with-connect image may differ from `rstudio/rstudio-connect`.) +Expected: with-connect still bootstraps an admin key (its JWT bootstrap is provider-independent) and the admin `GET /v1/user` returns 200. If bootstrap fails under PAM auth, record that — it means the gcfg must also keep whatever provider with-connect's default uses; iterate the gcfg minimally until both bootstrap and PAM login work. -- [ ] **Step 3: Determine how to create a publisher user + API key from the admin key** +- [ ] **Step 2: Create the publisher PAM user inside the container** -Write `scratch_spike.py` and try the rsconnect-native client first (no new deps): - -```python -import os -from rsconnect.api import RSConnectServer, RSConnectClient +```bash +docker exec -u root "$CID" bash -lc 'useradd -m -s /bin/bash susan && echo "susan:susan" | chpasswd && id susan' +``` +Expected: prints `uid=...(susan)`. (If `-u root` or `useradd` is unavailable on the with-connect image, record the correct path — e.g. a different base image user or a pre-seeded user via the gcfg.) -server = RSConnectServer(url=os.environ["CONNECT_SERVER"], api_key=os.environ["CONNECT_API_KEY"]) -client = RSConnectClient(server) +- [ ] **Step 3: Mint susan's API key (the crux)** -# Probe: what does the admin identity look like, and can we create a user? -print("me:", client.me()) -resp = client._server.handle_bad_response # noqa (inspect available methods) -print([m for m in dir(client) if "user" in m.lower() or "key" in m.lower()]) +First try a **raw-HTTP** reproduction of the old `_HackyConnect` flow (login as susan via the password provider, then create an API key through the session), e.g. probing: +```bash +# Inspect the login + key-create endpoints the web UI uses: +curl -i -X POST "$CONNECT_SERVER/__login__" -H 'Content-Type: application/json' -d '{"username":"susan","password":"susan"}' +# then, with the returned session cookie, POST to the api-keys creation endpoint ``` +Record the exact endpoints, payloads, and cookie handling that yield a working publisher key (verify by calling `GET /v1/user` with it and seeing a non-admin role). If reproducing the session/login flow proves too fiddly to be maintainable, fall back to adding `pins` as a **test-only** dependency and reusing `pins.rsconnect.api._HackyConnect` (which the old `dump_api_keys.py` used). Decide and record which approach Task 2 will implement. -Run: `uvx --with . python scratch_spike.py` -Expected: prints the admin identity and the available user/key methods. +- [ ] **Step 4: Confirm cache-dir manipulation via `docker exec`** -Decision point — pick the simplest mechanism that works against this Connect: - 1. **rsconnect client / raw REST**: `POST {server}/__api__/v1/users` to create a publisher, then mint a key. Verify the endpoint and key-creation route actually exist on this image. - 2. **Custom gcfg via the Action's `config-file`**: if API-driven user creation is not supported by with-connect's default auth provider, supply a minimal `rstudio-connect.gcfg` (password auth) plus a startup user, mirroring the old `vetiver-testing` setup but passed through `with-connect`'s `config-file` input. - -Record the working approach (exact REST calls or the gcfg + the publisher key retrieval) in the task notes — Task 2 consumes it. +```bash +docker exec -u rstudio-connect "$CID" mkdir -p /data/python-environments/_packages_cache/pip/1.2.3 +docker exec -u rstudio-connect "$CID" sh -c '[ -d /data/python-environments/_packages_cache/pip/1.2.3 ] && echo CACHE_OK' +``` +Expected: prints `CACHE_OK`. Record the correct exec user if `rstudio-connect` is wrong for this image. -- [ ] **Step 4: Tear down and clean up** +- [ ] **Step 5: Tear down** -Run: ```bash -uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop -rm -f scratch_spike.py +uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop "$CID" ``` -Expected: container stops; scratch file removed. **No commit** (spike produces notes only). +Keep the working `tests/connect/rstudio-connect.gcfg`; delete any other scratch. **No production commit** — write the resolved mechanism (gcfg contents, useradd command, exact key-mint approach, exec user) into the task notes for Tasks 2–3. --- -### Task 2: Refactor `test_main_system_caches.py` onto env creds + a publisher fixture +### Task 2: Re-home `test_main_system_caches.py` onto env creds + a publisher fixture **Files:** - Modify: `tests/test_main_system_caches.py` +- Create: `tests/connect/rstudio-connect.gcfg` (the validated gcfg from Task 1) +- Create: `tests/connect/bootstrap.py` (publisher useradd + key-mint helper), OR add `pins` as a test-only dep in `pyproject.toml` if Task 1 chose that fallback. **Interfaces:** -- Consumes (from env): `CONNECT_SERVER`, `CONNECT_API_KEY` (admin), `CONNECT_CONTAINER` (container id from with-connect start-only mode). -- Consumes (from Task 1): the verified publisher-user creation mechanism. +- Consumes (from env): `CONNECT_SERVER`, `CONNECT_API_KEY` (admin), `CONNECT_CONTAINER` (container id). +- Consumes (from Task 1): the validated gcfg, the useradd command, and the exact publisher-key-mint mechanism + exec user. - Produces: a `publisher_key` fixture; no dependency on `vetiver-testing/rsconnect_api_keys.json` or `docker compose`. -- [ ] **Step 1: Replace the module header (creds, container ref, docker commands)** +- [ ] **Step 1: Add the key-mint helper** -Replace lines 1–47 (imports through `apply_common_args`) of `tests/test_main_system_caches.py` with: +Create `tests/connect/bootstrap.py` implementing `make_publisher_key(server_url, container_id) -> str` using the EXACT mechanism Task 1 validated: `docker exec` the `useradd` for `susan`, then mint and return her API key (raw-HTTP session flow, or `_HackyConnect` if that was the chosen fallback). Keep it small and self-contained. + +- [ ] **Step 2: Rewrite the module header of `tests/test_main_system_caches.py`** + +Replace lines 1–47 (imports through `apply_common_args`) with creds-from-env + `docker exec ` cache commands + the publisher fixture wiring: ```python import os @@ -103,17 +120,17 @@ from os import system from click.testing import CliRunner from rsconnect.main import cli +from tests.connect.bootstrap import make_publisher_key CONNECT_SERVER = os.environ.get("CONNECT_SERVER", "http://localhost:3939") ADMIN_KEY = os.environ.get("CONNECT_API_KEY") CONTAINER = os.environ.get("CONNECT_CONTAINER", "") CONNECT_CACHE_DIR = "/data/python-environments/_packages_cache" -_EXEC = f"docker exec -u rstudio-connect -T {CONTAINER}" +_EXEC = f"docker exec -u rstudio-connect {CONTAINER}" ADD_CACHE_COMMAND = f"{_EXEC} mkdir -p {CONNECT_CACHE_DIR}/pip/1.2.3" RM_CACHE_COMMAND = f"{_EXEC} rm -Rf {CONNECT_CACHE_DIR}/pip/1.2.3" -# The following returns int(0) if dir exists, else nonzero. -CACHE_EXISTS_COMMAND = f"{_EXEC} [ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]" +CACHE_EXISTS_COMMAND = f"{_EXEC} sh -c '[ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]'" def rsconnect_service_running(): @@ -126,18 +143,9 @@ def cache_dir_exists(): return system(CACHE_EXISTS_COMMAND) == 0 -def make_publisher_key(): - """Create a non-admin publisher user via the admin key and return its API key. - - Implementation comes from Task 1's verified mechanism. Replace the body below - with the exact calls confirmed in the spike. - """ - from rsconnect.api import RSConnectClient, RSConnectServer # local import - - server = RSConnectServer(url=CONNECT_SERVER, api_key=ADMIN_KEY) - client = RSConnectClient(server) - # <-- insert the verified create-publisher-and-mint-key calls here --> - raise NotImplementedError("fill from Task 1 spike result") +PUBLISHER_KEY = ( + make_publisher_key(CONNECT_SERVER, CONTAINER) if (ADMIN_KEY and CONTAINER) else None +) def apply_common_args(args: list, server=None, key=None, insecure=True): @@ -149,34 +157,27 @@ def apply_common_args(args: list, server=None, key=None, insecure=True): args.extend(["--insecure"]) ``` -> Note: the `make_publisher_key` body is the single point that depends on the Task 1 spike. Everything else is final. Do not leave `NotImplementedError` in the committed version — Step 2 fills it. +> Use `-u rstudio-connect`/exec user exactly as Task 1 confirmed. If Task 1 found minting must happen lazily (not at import), move the `make_publisher_key` call into a module-scoped pytest fixture instead of a module constant. -- [ ] **Step 2: Fill `make_publisher_key` with the verified mechanism and add a module-level publisher key** - -Using the approach confirmed in Task 1, implement `make_publisher_key()` so it returns a usable publisher API key, then add below `apply_common_args`: - -```python -PUBLISHER_KEY = make_publisher_key() if ADMIN_KEY else None -``` +- [ ] **Step 3: Swap the key lookups in the test bodies** -Replace every `get_key("admin")` with `ADMIN_KEY` and every `get_key("susan")` with `PUBLISHER_KEY` in the test methods (lines ~67, 82, 109, 122, 137). Delete the old `get_key`, `CONNECT_KEYS_JSON`, and the `SERVICE_RUNNING_COMMAND` constant. +Replace each `get_key("admin")` with `ADMIN_KEY` and each `get_key("susan")` with `PUBLISHER_KEY` (lines ~67, 82, 109, 122, 137). Delete the old `get_key`, `CONNECT_KEYS_JSON`, and `SERVICE_RUNNING_COMMAND`. -- [ ] **Step 3: Verify the refactored test passes under with-connect start-only mode** +- [ ] **Step 4: Verify locally under with-connect start-only mode** -Run: ```bash -eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect)" +eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --config tests/connect/rstudio-connect.gcfg)" export CONNECT_CONTAINER="$(docker ps --format '{{.ID}}' --filter status=running | head -1)" uv run --no-sync pytest tests/test_main_system_caches.py -v -uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop +uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop "$CONNECT_CONTAINER" ``` -Expected: PASS — admin list/delete succeed (exit 0), publisher list/delete are denied (exit 1, "You don't have permission to perform this operation."). +Expected: PASS — admin list/delete exit 0; publisher list/delete exit 1 with "You don't have permission to perform this operation." -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash -git add tests/test_main_system_caches.py -git commit -m "test: run system-caches integration test via with-connect, create publisher at runtime" +git add tests/test_main_system_caches.py tests/connect/rstudio-connect.gcfg tests/connect/bootstrap.py +git commit -m "test: run system-caches integration test via with-connect with a slim publisher bootstrap" ``` --- @@ -187,13 +188,11 @@ git commit -m "test: run system-caches integration test via with-connect, create - Modify: `.github/workflows/main.yml` (the `test-dev-connect` job, lines ~183–211) **Interfaces:** -- Consumes: `secrets.RSC_LICENSE`; the refactored `test_main_system_caches.py` from Task 2. -- Produces: a job that runs only rsconnect's own integration test, with no vetiver install and no `docker compose`/`just dev`. +- Consumes: `secrets.RSC_LICENSE`; the refactored `test_main_system_caches.py` + `tests/connect/` from Task 2. +- Produces: a job running only rsconnect's own integration test, no vetiver install, no `docker compose`/`just dev`. - [ ] **Step 1: Replace the entire `test-dev-connect` job** -Replace the job (from ` test-dev-connect:` through the final `uv run --no-sync pytest --vetiver -m 'vetiver'` line) with: - ```yaml test-system-caches: name: "Integration tests against dev Connect" @@ -210,12 +209,16 @@ Replace the job (from ` test-dev-connect:` through the final `uv run --no-sync uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with: license: ${{ secrets.RSC_LICENSE }} + config-file: tests/connect/rstudio-connect.gcfg - name: Run system caches tests run: uv run --no-sync pytest tests/test_main_system_caches.py env: CONNECT_SERVER: ${{ steps.connect.outputs.CONNECT_SERVER }} CONNECT_API_KEY: ${{ steps.connect.outputs.CONNECT_API_KEY }} CONNECT_CONTAINER: ${{ steps.connect.outputs.CONTAINER_ID }} + - name: Get logs on failure + if: ${{ failure() }} + run: docker logs ${{ steps.connect.outputs.CONTAINER_ID }} - name: Stop Connect if: always() uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 @@ -224,7 +227,7 @@ Replace the job (from ` test-dev-connect:` through the final `uv run --no-sync stop: ${{ steps.connect.outputs.CONTAINER_ID }} ``` -> If the spike (Task 1) determined a custom gcfg is required to support publisher-user creation, add `config-file: ` to the `Start Connect` step and commit that gcfg alongside the test. +> The publisher `useradd` runs inside `make_publisher_key` (Task 2) via `docker exec`, so no separate CI step is needed for it. If Task 1 found `useradd` must run as a distinct step (e.g. timing/permissions), add a `docker exec` step between Start and the test, using `${{ steps.connect.outputs.CONTAINER_ID }}`. - [ ] **Step 2: Lint the workflow YAML** @@ -283,7 +286,7 @@ Change line ~74: markers = ["vetiver: tests for vetiver"] ``` -If `markers` is the only key that becomes empty, replace with an empty list: +to an empty list if it becomes empty: ```toml markers = [] @@ -309,13 +312,15 @@ git commit -m "test: drop --vetiver marker plumbing and test_vetiver_pins" --- -### Task 5: Delete the bespoke harness (vetiver-testing, docker-compose, Justfile dev recipes) +### Task 5: Delete the bespoke vetiver harness (vetiver-testing, docker-compose, Justfile dev recipes) **Files:** - Delete: `vetiver-testing/` (entire directory) - Delete: `docker-compose.yml` (root) - Modify: `Justfile` (remove `dev` and `dev-stop` recipes) +> The de-vetivered `tests/connect/` gcfg + helper from Task 2 are the replacement; this task removes only the old vetiver-named harness. + - [ ] **Step 1: Confirm nothing still references these paths** Run: @@ -346,7 +351,6 @@ dev-stop: - [ ] **Step 3: Delete the directories** -Run: ```bash git rm -r vetiver-testing git rm docker-compose.yml @@ -420,7 +424,7 @@ with: - [ ] **Step 3 (optional cleanup): Stop emitting spurious deprecation warnings on vetiver deploys** -`deploy_app` currently calls the local `validate_entry_point`/`validate_extra_files`, which each emit a `DeprecationWarning`. Point it at the active `bundle.py` versions instead. At the top of `actions.py`, confirm/add the import: +`deploy_app` calls the local `validate_entry_point`/`validate_extra_files`, which each emit a `DeprecationWarning`. Point it at the active `bundle.py` versions instead. At the top of `actions.py`, confirm/add: ```python from .bundle import validate_entry_point as _validate_entry_point @@ -441,16 +445,15 @@ to: kwargs["extra_files"] = extra_files = _validate_extra_files(directory, extra_files) # pyright: ignore ``` -Confirm the `bundle.py` signatures match: `validate_entry_point(entry_point, directory)` and `validate_extra_files(directory, extra_files)`. If the `bundle.py` `validate_extra_files` requires the extra `use_abspath` argument, pass its default explicitly. +Confirm the `bundle.py` signatures match: `validate_entry_point(entry_point, directory)` and `validate_extra_files(directory, extra_files)`. If `bundle.py`'s `validate_extra_files` requires an extra `use_abspath` argument, pass its default explicitly. - [ ] **Step 4: Verify the shim still imports and lints** -Run: ```bash uv run python -c "from rsconnect.actions import deploy_python_fastapi, deploy_app; print('ok')" just lint ``` -Expected: prints `ok`; `just lint` passes (ruff format check + ruff check). +Expected: prints `ok`; `just lint` passes. - [ ] **Step 5: Commit** @@ -461,10 +464,15 @@ git commit -m "docs: relabel deploy_python_fastapi as supported vetiver compat s --- +## Notes carried from the vetiver-python implementation + +- The vetiver side is done (PR rstudio/vetiver-python#242). It confirmed `with-connect`'s default Connect (v2026.x) + current FastAPI/pydantic require a Content-Type header (fixed in vetiver), used `content_list()` (not `content_search`), and validated the Action's start-only outputs. +- This plan's branch is based on `uv-tooling-modernization` (its exact-block edits target that branch's `Justfile`/`main.yml`/`conftest.py`), not `main`. + ## Self-Review notes -- Spec "rsconnect-python changes" → Task 1 (spike) + Task 2 (re-home test) + Task 3 (own CI job) cover keeping `test_main_system_caches.py`; Task 4–5 cover all deletions; Task 6 covers the relabel. -- The one genuinely uncertain piece (creating a publisher user / exec-ing the container under `with-connect`) is isolated to Task 1 and the `make_publisher_key` body in Task 2; everything else is concrete. -- Ordering keeps CI green: the system-caches test is re-homed (Tasks 2–3) before `vetiver-testing/` is deleted (Task 5). -- The `actions.py` shim is relabeled, never removed (Task 6); `bundle.py` validate functions are untouched except as an explicit import in the optional cleanup. -- `with-connect` SHA `0783dabdd24e360e985a4588ce1239c3dc31c542` is used identically across Tasks 1–3. +- Spec "rsconnect-python changes" → Task 1 (spike) + Task 2 (re-home test, gcfg+helper) + Task 3 (own CI job) cover keeping `test_main_system_caches.py`; Tasks 4–5 cover all deletions; Task 6 covers the relabel. +- The genuinely uncertain pieces (gcfg/JWT coexistence, publisher-key mint, exec user) are isolated to Task 1; Task 2/3 consume its validated outputs. No fabricated key-mint code is committed before the spike confirms it. +- Ordering keeps CI green: the system-caches test is re-homed + its CI job stood up (Tasks 2–3) before `vetiver-testing/` is deleted (Task 5). +- The shim is relabeled, never removed (Task 6); `bundle.py` validate functions untouched except as an explicit import in the optional cleanup. +- `with-connect` SHA `0783dabdd24e360e985a4588ce1239c3dc31c542` used identically across Tasks 1–3. From 541b0627de7e95a2505f3b2e782c49965b575729 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 16:51:56 -0400 Subject: [PATCH 3/9] =?UTF-8?q?docs:=20simplify=20rsconnect=20plan=20?= =?UTF-8?q?=E2=80=94=20mock=20system-caches=20CLI,=20drop=20live=20Connect?= =?UTF-8?q?=20entirely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-06-30-rsconnect-drop-vetiver-baggage.md | 299 +++++------------- 1 file changed, 74 insertions(+), 225 deletions(-) diff --git a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md index 78977f61..835f2fb6 100644 --- a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md +++ b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md @@ -1,249 +1,69 @@ -# rsconnect-python: drop vetiver test baggage, adopt with-connect — Implementation Plan +# rsconnect-python: drop vetiver test baggage — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Remove all *vetiver-specific* test code from rsconnect-python, re-home its own `system caches` integration test onto `posit-dev/with-connect`, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. +**Goal:** Remove all vetiver-specific test code from rsconnect-python, convert its own `system caches` integration test to an offline `httpretty`-mocked unit test (no live Connect), drop the now-empty `test-dev-connect` CI job and the bespoke Connect harness, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. -**Architecture:** `with-connect` boots a licensed Connect container and (in start-only mode) yields an admin API key + the container id. rsconnect's only Connect integration test (`test_main_system_caches.py`) asserts an admin can list/delete caches and a **non-admin publisher is denied (403)**, so it genuinely needs two privilege levels. Since Connect has no public "admin mints another user's key" endpoint, we keep a **minimal** Connect test bootstrap — a password-auth `gcfg` passed via the Action's `config-file` input, a `useradd` of one publisher inside the container, and a small helper that mints that publisher's key via Connect's signup/session flow. This replaces the old `docker-compose` + multi-user `vetiver-testing/` harness with `with-connect` + a slim, de-vetivered config. The vetiver↔Connect tests move entirely to the vetiver repo. +**Architecture:** rsconnect's `system caches list/delete` CLI commands are thin wrappers over `GET`/`DELETE v1/system/caches/runtime`. Connect's *permission enforcement* on that endpoint (admin allowed; publisher/viewer/anon → 403) is already fully covered upstream in `connect/test/api/tests/test_system_caches_runtime.py`, so rsconnect does not need a live Connect or a second user to re-test it. Instead rsconnect tests its own CLI layer — command wiring, required-flag validation, output formatting, and error surfacing — with `httpretty`, the repo's established HTTP-mocking approach. This removes rsconnect's need for `with-connect` entirely. -**Tech Stack:** Python, pytest, Click, Docker, `uv`, GitHub Actions, `just`. +**Tech Stack:** Python, pytest, Click (`CliRunner`), httpretty. ## Global Constraints -- Pin `with-connect` to commit `0783dabdd24e360e985a4588ce1239c3dc31c542` (no release tags exist yet). Verify at execution time with `gh api repos/posit-dev/with-connect/commits/main -q .sha`. -- **Confirmed `with-connect` Action API** (verified against `action.yml@main`): inputs include `license`, `version`, `config-file`, `env`, `command`, `stop`; in **start-only** mode (no `command`) it sets outputs `CONNECT_SERVER`, `CONNECT_API_KEY`, `CONTAINER_ID`. The `system caches` job uses start-only mode and runs `pytest` as a normal `uv run` step (so the "`pytest` not found inside a wrapped `with-connect -- pytest`" gotcha does not apply here). -- The container runs via plain `docker` (no `docker compose`). Cache-dir setup uses `docker exec ...`, not `docker compose exec`. +- **No live Connect / no `with-connect` for rsconnect.** Every test in this plan runs offline; the plan is executable without a license or Docker. +- The `system caches` CLI hits `v1/system/caches/runtime` (`rsconnect/api.py:982-988`); mocks must target that path. The CLI builds an `RSConnectExecutor(...).validate_server()` first, so the mock must also satisfy server validation — mirror the httpretty server setup already used in `tests/test_main_content.py` / `tests/test_main_integration.py`. +- Connect permission enforcement is relied upon from upstream `connect/test/api/tests/test_system_caches_runtime.py` (already covers all four roles for these endpoints). Do not re-test enforcement against a live server here. - The `actions.py` shim (`deploy_python_fastapi` → `deploy_app` → `validate_*`, lines ~281–442) is **kept**. Do not delete it. Do not alter the active `validate_*` in `bundle.py`. -- `pins` is **not** a dependency of rsconnect-python. The old key-mint used `pins.rsconnect.api._HackyConnect`. The publisher-key helper here should prefer a small self-contained raw-HTTP reproduction; only if that proves too fiddly, add `pins` as a **test-only** dependency (Task 1 decides). -- A valid `rstudio-connect.lic` must be present in the repo root for local runs; CI passes it via the `RSC_LICENSE` secret. -- Keep CI green at every commit: re-home `test_main_system_caches.py` and stand up its new CI job (Tasks 1–3) **before** deleting `vetiver-testing/` (Task 5). +- This branch is based on `uv-tooling-modernization` (its exact-block edits target that branch's `Justfile`/`main.yml`/`conftest.py`), not `main`. +- Keep CI green at every commit: rewrite `test_main_system_caches.py` (Task 1) and remove the job that depended on the old harness (Task 3) **before** deleting `vetiver-testing/` (Task 4). --- -### Task 1: Spike — gcfg + JWT-bootstrap coexistence and publisher-key minting - -Discovery task (no production commit). Resolve, against a live `with-connect` Connect, the exact mechanism the dependent tasks consume. Record findings in the task notes; Tasks 2–3 are written against them. +### Task 1: Rewrite `test_main_system_caches.py` as an httpretty-mocked unit test **Files:** -- Create (temporary, for the spike only): `scratch/` artifacts you delete at the end. A candidate `tests/connect/rstudio-connect.gcfg` you iterate on (kept if it works — see Task 2). - -Base the candidate gcfg on the old `vetiver-testing/setup-rsconnect/rstudio-connect.gcfg` but **drop the `[Python]` section** (its image-specific executable paths won't match the with-connect image, and the system-caches test runs no Python content). Keep PAM auth and `DefaultUserRole = publisher`: - -```ini -[Server] -DataDir = /data -Address = http://localhost:3939 - -[HTTP] -Listen = :3939 - -[Authentication] -Provider = pam - -[Authorization] -DefaultUserRole = publisher - -[Logging] -ServiceLog = STDOUT -``` - -- [ ] **Step 1: Confirm with-connect's JWT bootstrap coexists with the PAM gcfg** - -Start Connect with the candidate config (start-only). Use the CLI's `--config` (the CLI equivalent of the Action's `config-file`): -```bash -eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --config tests/connect/rstudio-connect.gcfg)" -echo "server=$CONNECT_SERVER key set=${CONNECT_API_KEY:+yes}" -CID=$(docker ps --format '{{.ID}}' --filter status=running | head -1); echo "container=$CID" -curl -fsS -H "Authorization: Key $CONNECT_API_KEY" "$CONNECT_SERVER/__api__/v1/user" && echo OK -``` -Expected: with-connect still bootstraps an admin key (its JWT bootstrap is provider-independent) and the admin `GET /v1/user` returns 200. If bootstrap fails under PAM auth, record that — it means the gcfg must also keep whatever provider with-connect's default uses; iterate the gcfg minimally until both bootstrap and PAM login work. - -- [ ] **Step 2: Create the publisher PAM user inside the container** - -```bash -docker exec -u root "$CID" bash -lc 'useradd -m -s /bin/bash susan && echo "susan:susan" | chpasswd && id susan' -``` -Expected: prints `uid=...(susan)`. (If `-u root` or `useradd` is unavailable on the with-connect image, record the correct path — e.g. a different base image user or a pre-seeded user via the gcfg.) - -- [ ] **Step 3: Mint susan's API key (the crux)** - -First try a **raw-HTTP** reproduction of the old `_HackyConnect` flow (login as susan via the password provider, then create an API key through the session), e.g. probing: -```bash -# Inspect the login + key-create endpoints the web UI uses: -curl -i -X POST "$CONNECT_SERVER/__login__" -H 'Content-Type: application/json' -d '{"username":"susan","password":"susan"}' -# then, with the returned session cookie, POST to the api-keys creation endpoint -``` -Record the exact endpoints, payloads, and cookie handling that yield a working publisher key (verify by calling `GET /v1/user` with it and seeing a non-admin role). If reproducing the session/login flow proves too fiddly to be maintainable, fall back to adding `pins` as a **test-only** dependency and reusing `pins.rsconnect.api._HackyConnect` (which the old `dump_api_keys.py` used). Decide and record which approach Task 2 will implement. - -- [ ] **Step 4: Confirm cache-dir manipulation via `docker exec`** - -```bash -docker exec -u rstudio-connect "$CID" mkdir -p /data/python-environments/_packages_cache/pip/1.2.3 -docker exec -u rstudio-connect "$CID" sh -c '[ -d /data/python-environments/_packages_cache/pip/1.2.3 ] && echo CACHE_OK' -``` -Expected: prints `CACHE_OK`. Record the correct exec user if `rstudio-connect` is wrong for this image. - -- [ ] **Step 5: Tear down** - -```bash -uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop "$CID" -``` -Keep the working `tests/connect/rstudio-connect.gcfg`; delete any other scratch. **No production commit** — write the resolved mechanism (gcfg contents, useradd command, exact key-mint approach, exec user) into the task notes for Tasks 2–3. - ---- - -### Task 2: Re-home `test_main_system_caches.py` onto env creds + a publisher fixture - -**Files:** -- Modify: `tests/test_main_system_caches.py` -- Create: `tests/connect/rstudio-connect.gcfg` (the validated gcfg from Task 1) -- Create: `tests/connect/bootstrap.py` (publisher useradd + key-mint helper), OR add `pins` as a test-only dep in `pyproject.toml` if Task 1 chose that fallback. +- Modify: `tests/test_main_system_caches.py` (full rewrite — remove all live-Connect/docker/JSON-key machinery) **Interfaces:** -- Consumes (from env): `CONNECT_SERVER`, `CONNECT_API_KEY` (admin), `CONNECT_CONTAINER` (container id). -- Consumes (from Task 1): the validated gcfg, the useradd command, and the exact publisher-key-mint mechanism + exec user. -- Produces: a `publisher_key` fixture; no dependency on `vetiver-testing/rsconnect_api_keys.json` or `docker compose`. - -- [ ] **Step 1: Add the key-mint helper** - -Create `tests/connect/bootstrap.py` implementing `make_publisher_key(server_url, container_id) -> str` using the EXACT mechanism Task 1 validated: `docker exec` the `useradd` for `susan`, then mint and return her API key (raw-HTTP session flow, or `_HackyConnect` if that was the chosen fallback). Keep it small and self-contained. - -- [ ] **Step 2: Rewrite the module header of `tests/test_main_system_caches.py`** - -Replace lines 1–47 (imports through `apply_common_args`) with creds-from-env + `docker exec ` cache commands + the publisher fixture wiring: - -```python -import os -import unittest -from os import system - -from click.testing import CliRunner - -from rsconnect.main import cli -from tests.connect.bootstrap import make_publisher_key - -CONNECT_SERVER = os.environ.get("CONNECT_SERVER", "http://localhost:3939") -ADMIN_KEY = os.environ.get("CONNECT_API_KEY") -CONTAINER = os.environ.get("CONNECT_CONTAINER", "") -CONNECT_CACHE_DIR = "/data/python-environments/_packages_cache" - -_EXEC = f"docker exec -u rstudio-connect {CONTAINER}" -ADD_CACHE_COMMAND = f"{_EXEC} mkdir -p {CONNECT_CACHE_DIR}/pip/1.2.3" -RM_CACHE_COMMAND = f"{_EXEC} rm -Rf {CONNECT_CACHE_DIR}/pip/1.2.3" -CACHE_EXISTS_COMMAND = f"{_EXEC} sh -c '[ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]'" +- Consumes: nothing external (fully mocked). +- Produces: an offline test module that runs in the default `pytest ./tests/` suite (no marker, no skip guard). +- [ ] **Step 1: Study the existing CLI-against-mocked-Connect pattern** -def rsconnect_service_running(): - if not CONTAINER: - return False - return system(f"docker inspect -f '{{{{.State.Running}}}}' {CONTAINER}") == 0 +Read `tests/test_main_content.py` (and `tests/test_main_integration.py`) for how a `CliRunner().invoke(cli, ...)` flow is run against an `httpretty`-mocked Connect, specifically how server validation is satisfied (the endpoints `RSConnectExecutor.validate_server()` calls — typically the server settings + current-user/verify endpoints). Reuse that exact setup so the executor validates without a real server. Identify the helper or the set of `register_uri` calls needed and report them in your report. +- [ ] **Step 2: Write the mocked tests** -def cache_dir_exists(): - return system(CACHE_EXISTS_COMMAND) == 0 +Replace the entire contents of `tests/test_main_system_caches.py`. Keep the four behaviors the old test covered, now mocked (no `admin`/`susan` users, no docker, no JSON keys). Use the repo's `@httpretty.activate(verbose=True, allow_net_connect=False)` style and `CliRunner`. The endpoint is `v1/system/caches/runtime`. +Cover: +1. **`list` happy path** — register `GET v1/system/caches/runtime` returning a caches payload (e.g. `{"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]}`); invoke `system caches list`; assert exit 0 and that stdout JSON matches. +2. **`delete` happy path** — register `DELETE v1/system/caches/runtime` returning success; invoke `system caches delete --language Python --version 1.2.3 --image-name Local`; assert exit 0. +3. **required-flag validation** — invoke `system caches delete` with no flags (and with only `--language`); assert exit code 2 and the `Missing option '--language' / '-l'` / `--version` messages. (No server interaction occurs; flag parsing fails first — these may not even need httpretty.) +4. **permission surfacing** — register the cache endpoint to return `403` with Connect's permission-denied body; invoke the command; assert exit code 1 and that the output contains the permission-denied message. This replaces the old live "publisher is denied" assertion: it verifies the CLI *surfaces* a 403, while Connect's actual enforcement is covered upstream. -PUBLISHER_KEY = ( - make_publisher_key(CONNECT_SERVER, CONTAINER) if (ADMIN_KEY and CONTAINER) else None -) +Mirror `tests/test_main_content.py` for the server-validation `register_uri` calls and for `apply_common_args`-style `-s`/`-k`/`--insecure` argument wiring (keep a local `apply_common_args` helper or inline the args). +- [ ] **Step 3: Run the test offline** -def apply_common_args(args: list, server=None, key=None, insecure=True): - if server: - args.extend(["-s", server]) - if key: - args.extend(["-k", key]) - if insecure: - args.extend(["--insecure"]) -``` - -> Use `-u rstudio-connect`/exec user exactly as Task 1 confirmed. If Task 1 found minting must happen lazily (not at import), move the `make_publisher_key` call into a module-scoped pytest fixture instead of a module constant. - -- [ ] **Step 3: Swap the key lookups in the test bodies** +Run: `uv run pytest tests/test_main_system_caches.py -v` +Expected: all cases PASS with no network access (httpretty `allow_net_connect=False`) and no Docker. -Replace each `get_key("admin")` with `ADMIN_KEY` and each `get_key("susan")` with `PUBLISHER_KEY` (lines ~67, 82, 109, 122, 137). Delete the old `get_key`, `CONNECT_KEYS_JSON`, and `SERVICE_RUNNING_COMMAND`. +- [ ] **Step 4: Confirm it is collected by the default suite** -- [ ] **Step 4: Verify locally under with-connect start-only mode** - -```bash -eval "$(uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --config tests/connect/rstudio-connect.gcfg)" -export CONNECT_CONTAINER="$(docker ps --format '{{.ID}}' --filter status=running | head -1)" -uv run --no-sync pytest tests/test_main_system_caches.py -v -uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 with-connect --stop "$CONNECT_CONTAINER" -``` -Expected: PASS — admin list/delete exit 0; publisher list/delete exit 1 with "You don't have permission to perform this operation." +Run: `uv run pytest --collect-only -q tests/test_main_system_caches.py` +Expected: the new tests are collected with no special marker or `--vetiver`/live-Connect skip. (It will now run as part of `scripts/runtests` on every PR.) - [ ] **Step 5: Commit** ```bash -git add tests/test_main_system_caches.py tests/connect/rstudio-connect.gcfg tests/connect/bootstrap.py -git commit -m "test: run system-caches integration test via with-connect with a slim publisher bootstrap" +git add tests/test_main_system_caches.py +git commit -m "test: cover system caches CLI with httpretty mocks instead of a live Connect" ``` --- -### Task 3: Replace the `test-dev-connect` CI job with an rsconnect-own with-connect job - -**Files:** -- Modify: `.github/workflows/main.yml` (the `test-dev-connect` job, lines ~183–211) - -**Interfaces:** -- Consumes: `secrets.RSC_LICENSE`; the refactored `test_main_system_caches.py` + `tests/connect/` from Task 2. -- Produces: a job running only rsconnect's own integration test, no vetiver install, no `docker compose`/`just dev`. - -- [ ] **Step 1: Replace the entire `test-dev-connect` job** - -```yaml - test-system-caches: - name: "Integration tests against dev Connect" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v6 - with: - version: ">=0.9.0" - - name: Install dependencies - run: uv sync --python 3.12 --group test - - name: Start Connect - id: connect - uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 - with: - license: ${{ secrets.RSC_LICENSE }} - config-file: tests/connect/rstudio-connect.gcfg - - name: Run system caches tests - run: uv run --no-sync pytest tests/test_main_system_caches.py - env: - CONNECT_SERVER: ${{ steps.connect.outputs.CONNECT_SERVER }} - CONNECT_API_KEY: ${{ steps.connect.outputs.CONNECT_API_KEY }} - CONNECT_CONTAINER: ${{ steps.connect.outputs.CONTAINER_ID }} - - name: Get logs on failure - if: ${{ failure() }} - run: docker logs ${{ steps.connect.outputs.CONTAINER_ID }} - - name: Stop Connect - if: always() - uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 - with: - license: ${{ secrets.RSC_LICENSE }} - stop: ${{ steps.connect.outputs.CONTAINER_ID }} -``` - -> The publisher `useradd` runs inside `make_publisher_key` (Task 2) via `docker exec`, so no separate CI step is needed for it. If Task 1 found `useradd` must run as a distinct step (e.g. timing/permissions), add a `docker exec` step between Start and the test, using `${{ steps.connect.outputs.CONTAINER_ID }}`. - -- [ ] **Step 2: Lint the workflow YAML** - -Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/main.yml'))"` -Expected: no output (valid YAML). - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/main.yml -git commit -m "ci: run system-caches integration test via with-connect; drop vetiver job" -``` - ---- - -### Task 4: Remove the `--vetiver` marker plumbing and the vetiver test +### Task 2: Remove the `--vetiver` marker plumbing and the vetiver test **Files:** - Modify: `conftest.py` @@ -312,14 +132,44 @@ git commit -m "test: drop --vetiver marker plumbing and test_vetiver_pins" --- -### Task 5: Delete the bespoke vetiver harness (vetiver-testing, docker-compose, Justfile dev recipes) +### Task 3: Remove the `test-dev-connect` CI job + +**Files:** +- Modify: `.github/workflows/main.yml` (delete the `test-dev-connect` job, lines ~183–211) + +The job existed only to run the vetiver test and the old live `test_main_system_caches.py`. With the vetiver test deleted (Task 2) and system caches now an offline unit test that runs in the normal suite (Task 1), the job has nothing left to do. + +- [ ] **Step 1: Delete the entire `test-dev-connect` job** + +Remove the job block (from ` test-dev-connect:` through its final `uv run --no-sync pytest --vetiver -m 'vetiver'` line). Do not add a replacement. The mocked `test_main_system_caches.py` now runs in the standard test job via `scripts/runtests`. + +- [ ] **Step 2: Confirm no other job references the deleted job or the harness** + +Run: `grep -n "test-dev-connect\|vetiver\|just dev\|docker compose" .github/workflows/main.yml` +Expected: no hits (or only unrelated ones you can explain). Confirm no `needs:` in another job points at `test-dev-connect`. + +- [ ] **Step 3: Lint the workflow YAML** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/main.yml'))"` +Expected: no output (valid YAML). + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/main.yml +git commit -m "ci: drop test-dev-connect job (system caches now mocked, vetiver test removed)" +``` + +--- + +### Task 4: Delete the bespoke vetiver harness (vetiver-testing, docker-compose, Justfile dev recipes) **Files:** - Delete: `vetiver-testing/` (entire directory) - Delete: `docker-compose.yml` (root) - Modify: `Justfile` (remove `dev` and `dev-stop` recipes) -> The de-vetivered `tests/connect/` gcfg + helper from Task 2 are the replacement; this task removes only the old vetiver-named harness. +With no live Connect test remaining in rsconnect, none of this has a consumer. - [ ] **Step 1: Confirm nothing still references these paths** @@ -361,8 +211,8 @@ git rm docker-compose.yml Run: `just --list` Expected: lists recipes with no `dev`/`dev-stop`, no parse error. -Run: `uv run pytest -q -k "not system_caches"` -Expected: PASS (the offline unit suite is unaffected). +Run: `uv run pytest -q` +Expected: PASS (the full offline suite, now including the mocked `test_main_system_caches.py`). - [ ] **Step 5: Commit** @@ -373,7 +223,7 @@ git commit -m "chore: remove vetiver test harness and root docker-compose" --- -### Task 6: Relabel the `actions.py` compatibility shim +### Task 5: Relabel the `actions.py` compatibility shim **Files:** - Modify: `rsconnect/actions.py` (comment block markers at lines ~281–285 and ~441–442; optional `validate_*` swap) @@ -464,15 +314,14 @@ git commit -m "docs: relabel deploy_python_fastapi as supported vetiver compat s --- -## Notes carried from the vetiver-python implementation +## Why no live Connect / no with-connect for rsconnect -- The vetiver side is done (PR rstudio/vetiver-python#242). It confirmed `with-connect`'s default Connect (v2026.x) + current FastAPI/pydantic require a Content-Type header (fixed in vetiver), used `content_list()` (not `content_search`), and validated the Action's start-only outputs. -- This plan's branch is based on `uv-tooling-modernization` (its exact-block edits target that branch's `Justfile`/`main.yml`/`conftest.py`), not `main`. +The earlier draft of this plan kept a live Connect test (with a gcfg + `useradd` + key-mint helper) to exercise the publisher-denied path. Investigation showed that path re-tests *Connect's* permission enforcement, which is already covered upstream in `connect/test/api/tests/test_system_caches_runtime.py` for all four roles on the same `v1/system/caches/runtime` endpoint. rsconnect's job is to test its CLI wiring, which `httpretty` mocks do offline. This deletes the spike, the gcfg/helper, and the with-connect CI job, and moves system-caches coverage into the default per-PR suite. (vetiver-python, by contrast, genuinely deploys a model and serves predictions, so it keeps a live `with-connect` test — see rstudio/vetiver-python#242.) ## Self-Review notes -- Spec "rsconnect-python changes" → Task 1 (spike) + Task 2 (re-home test, gcfg+helper) + Task 3 (own CI job) cover keeping `test_main_system_caches.py`; Tasks 4–5 cover all deletions; Task 6 covers the relabel. -- The genuinely uncertain pieces (gcfg/JWT coexistence, publisher-key mint, exec user) are isolated to Task 1; Task 2/3 consume its validated outputs. No fabricated key-mint code is committed before the spike confirms it. -- Ordering keeps CI green: the system-caches test is re-homed + its CI job stood up (Tasks 2–3) before `vetiver-testing/` is deleted (Task 5). -- The shim is relabeled, never removed (Task 6); `bundle.py` validate functions untouched except as an explicit import in the optional cleanup. -- `with-connect` SHA `0783dabdd24e360e985a4588ce1239c3dc31c542` used identically across Tasks 1–3. +- Every task is offline and locally verifiable (no license/Docker). +- Task 1 preserves the CLI behaviors the old test covered (list, delete, required-flag validation, 403 surfacing) via mocks; real enforcement is covered upstream. +- Ordering keeps CI green: system-caches rewritten (Task 1) and the dependent job removed (Task 3) before the harness is deleted (Task 4). +- The shim is relabeled, never removed (Task 5); `bundle.py` validate functions untouched except as an explicit import in the optional cleanup. +- No external action pin, no with-connect dependency anywhere in this plan. From 1366ae176e9e4976a0dde3ec6fd5c261fe3ec18c Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 16:56:57 -0400 Subject: [PATCH 4/9] test: cover system caches CLI with httpretty mocks instead of a live Connect --- tests/test_main_system_caches.py | 230 ++++++++++++++++--------------- 1 file changed, 117 insertions(+), 113 deletions(-) diff --git a/tests/test_main_system_caches.py b/tests/test_main_system_caches.py index fe1f32b5..14500ab9 100644 --- a/tests/test_main_system_caches.py +++ b/tests/test_main_system_caches.py @@ -1,159 +1,163 @@ import json import unittest -from os import system +import httpretty from click.testing import CliRunner from rsconnect.main import cli +from .utils import apply_common_args CONNECT_SERVER = "http://localhost:3939" -CONNECT_KEYS_JSON = "vetiver-testing/rsconnect_api_keys.json" -CONNECT_CACHE_DIR = "/data/python-environments/_packages_cache" +API_KEY = "testapikey123" -ADD_CACHE_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect mkdir -p {CONNECT_CACHE_DIR}/pip/1.2.3" -RM_CACHE_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect rm -Rf {CONNECT_CACHE_DIR}/pip/1.2.3" -# The following returns int(0) if dir exists, else int(256). -CACHE_EXISTS_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect [ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]" -SERVICE_RUNNING_COMMAND = "docker compose ps --services --filter 'status=running' | grep rsconnect" +CACHES_PAYLOAD = {"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]} +PERMISSION_DENIED_BODY = json.dumps({"code": 22, "error": "You don't have permission to perform this operation."}) -def rsconnect_service_running(): - exit_code = system(SERVICE_RUNNING_COMMAND) - if exit_code == 0: - return True - else: - return False - -def cache_dir_exists(): - exit_code = system(CACHE_EXISTS_COMMAND) - if exit_code == 0: - return True - else: - return False - - -def get_key(name): - with open(CONNECT_KEYS_JSON) as f: - api_key = json.load(f)[name] - return api_key - - -def apply_common_args(args: list, server=None, key=None, insecure=True): - if server: - args.extend(["-s", server]) - if key: - args.extend(["-k", key]) - if insecure: - args.extend(["--insecure"]) +def register_server_validation_uris(connect_server: str): + """Register the endpoints that RSConnectExecutor.validate_server() requires.""" + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/server_settings", + body=open("tests/testdata/connect-responses/server_settings.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) class TestSystemCachesList(unittest.TestCase): - @classmethod - def setUpClass(cls): - system(ADD_CACHE_COMMAND) - if not rsconnect_service_running(): - raise unittest.SkipTest("rsconnect docker service is not available") - return super().setUpClass() - - @classmethod - def tearDownClass(cls): - system(RM_CACHE_COMMAND) - return super().tearDownClass - - # Admins can list caches - def test_system_caches_list_admin(self): - api_key = get_key("admin") - runner = CliRunner() + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_list_happy_path(self): + """Admin can list caches; stdout JSON matches the mocked payload.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + body=json.dumps(CACHES_PAYLOAD), + adding_headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() args = ["system", "caches", "list"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0) - expected = {"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]} + self.assertEqual(result.exit_code, 0, result.output) result_dict = json.loads(result.output) - self.assertDictEqual(result_dict, expected) + self.assertDictEqual(result_dict, CACHES_PAYLOAD) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_list_permission_denied(self): + """A 403 from Connect is surfaced as exit code 1 with the permission message.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=403, + body=PERMISSION_DENIED_BODY, + adding_headers={"Content-Type": "application/json"}, + ) - # Publishers cannot list caches - def test_system_caches_list_publisher(self): - api_key = get_key("susan") runner = CliRunner() - args = ["system", "caches", "list"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 1) + self.assertEqual(result.exit_code, 1, result.output) self.assertRegex(result.output, "You don't have permission to perform this operation.") class TestSystemCachesDelete(unittest.TestCase): - @classmethod - def setUpClass(cls): - system(ADD_CACHE_COMMAND) - if not rsconnect_service_running(): - raise unittest.SkipTest("rsconnect docker service is not available") - return super().setUpClass() - - @classmethod - def tearDownClass(cls): - system(RM_CACHE_COMMAND) - return super().tearDownClass - - # Publishers cannot delete caches - def test_system_caches_delete_publisher(self): - api_key = get_key("susan") - runner = CliRunner() + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_delete_happy_path(self): + """Admin can delete a cache; exit code 0.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.DELETE, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=200, + body=json.dumps( + {"language": "Python", "version": "1.2.3", "image_name": "Local", "dry_run": False, "task_id": "abc123"} + ), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/tasks/abc123", + body=json.dumps( + { + "id": "abc123", + "output": [], + "result": {"type": "", "data": ""}, + "finished": True, + "code": 0, + "error": "", + "last": 0, + } + ), + adding_headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.output, "You don't have permission to perform this operation.") + self.assertEqual(result.exit_code, 0, result.output) - # Admins can delete caches that exist - def test_system_caches_delete_admin(self): - api_key = get_key("admin") + def test_system_caches_delete_missing_all_flags(self): + """Omitting both --language and --version yields exit code 2 (Click validation).""" runner = CliRunner() + args = ["system", "caches", "delete"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) + result = runner.invoke(cli, args) - args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--language' / '-l'") - self.assertTrue(cache_dir_exists()) + def test_system_caches_delete_missing_version_flag(self): + """Providing --language but omitting --version yields exit code 2.""" + runner = CliRunner() + args = ["system", "caches", "delete", "--language", "Python"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0) - self.assertFalse(cache_dir_exists()) - # TODO: Unsure how to test log messages received from Connect. + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--version' / '-V'") - # --version and --language flags are required - def test_system_caches_delete_required_flags(self): - api_key = get_key("admin") + def test_system_caches_delete_missing_language_flag(self): + """Providing --version but omitting --language yields exit code 2.""" runner = CliRunner() - - # neither flag provided should fail - args = ["system", "caches", "delete"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + args = ["system", "caches", "delete", "--version", "1.2.3"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--language' / '-l'") - # only --language flag provided should fail - args = ["system", "caches", "delete", "--language", "Python"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--version' / '-V'") + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--language' / '-l'") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_delete_permission_denied(self): + """A 403 from Connect on delete is surfaced as exit code 1 with the permission message.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.DELETE, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=403, + body=PERMISSION_DENIED_BODY, + adding_headers={"Content-Type": "application/json"}, + ) - # only --version flag provided should fail - args = ["system", "caches", "delete", "--version", "1.2.3"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + runner = CliRunner() + args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--language' / '-l'") + + self.assertEqual(result.exit_code, 1, result.output) + self.assertRegex(result.output, "You don't have permission to perform this operation.") From f39597302f1c6dd1537d5087dac6bc84a6e3e86a Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 16:59:18 -0400 Subject: [PATCH 5/9] test: drop --vetiver marker plumbing and test_vetiver_pins --- conftest.py | 18 -------- pyproject.toml | 4 +- tests/test_vetiver_pins.py | 95 -------------------------------------- 3 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 tests/test_vetiver_pins.py diff --git a/conftest.py b/conftest.py index 44fab2be..4cb10b07 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,5 @@ import os import sys -import pytest from os.path import abspath, dirname @@ -13,20 +12,3 @@ # default argument value at import time, so this must be set before any test # module imports rsconnect. (Previously injected by the Makefile's TEST_ENV.) os.environ.setdefault("CONNECT_CONTENT_BUILD_DIR", "rsconnect-build-test") - - -def pytest_addoption(parser): - parser.addoption("--vetiver", action="store_true", default=False, help="run vetiver tests") - - -def pytest_configure(config): - config.addinivalue_line("markers", "vetiver: test for vetiver interaction") - - -def pytest_collection_modifyitems(config, items): - if config.getoption("--vetiver"): - return - skip_vetiver = pytest.mark.skip(reason="need --vetiver option to run") - for item in items: - if "vetiver" in item.keywords: - item.add_marker(skip_vetiver) diff --git a/pyproject.toml b/pyproject.toml index 2890f480..8fb46e08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ module-root = "" [tool.ruff] line-length = 120 -extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "vetiver-testing", "tests/testdata"] +extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "tests/testdata"] [tool.ruff.lint] select = ["E", "F", "W"] @@ -71,7 +71,7 @@ per-file-ignores = { "tests/test_metadata.py" = ["E501"] } omit = ["tests/*"] [tool.pytest.ini_options] -markers = ["vetiver: tests for vetiver"] +markers = [] addopts = """ --ignore=tests/testdata """ diff --git a/tests/test_vetiver_pins.py b/tests/test_vetiver_pins.py deleted file mode 100644 index 5b187794..00000000 --- a/tests/test_vetiver_pins.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest - -vetiver = pytest.importorskip("vetiver", reason="vetiver library not installed") - -import json # noqa -import pins # noqa -import pandas as pd # noqa -import numpy as np # noqa - -from pins.boards import BoardRsConnect # noqa -from pins.rsconnect.api import RsConnectApi # noqa -from pins.rsconnect.fs import RsConnectFs # noqa -from rsconnect.api import RSConnectServer, RSConnectClient # noqa - -RSC_SERVER_URL = "http://localhost:3939" -RSC_KEYS_FNAME = "vetiver-testing/rsconnect_api_keys.json" - -pytestmark = pytest.mark.vetiver # noqa - - -def get_key(name): - with open(RSC_KEYS_FNAME) as f: - api_key = json.load(f)[name] - return api_key - - -def rsc_from_key(name): - with open(RSC_KEYS_FNAME) as f: - api_key = json.load(f)[name] - return RsConnectApi(RSC_SERVER_URL, api_key) - - -def rsc_fs_from_key(name): - - rsc = rsc_from_key(name) - - return RsConnectFs(rsc) - - -def rsc_delete_user_content(rsc): - guid = rsc.get_user()["guid"] - content = rsc.get_content(owner_guid=guid) - for entry in content: - rsc.delete_content_item(entry["guid"]) - - -@pytest.fixture(scope="function") -def rsc_short(): - # tears down content after each test - fs_susan = rsc_fs_from_key("susan") - - # delete any content that might already exist - rsc_delete_user_content(fs_susan.api) - - yield BoardRsConnect("", fs_susan, allow_pickle_read=True) # fs_susan.ls to list content - - rsc_delete_user_content(fs_susan.api) - - -def test_deploy(rsc_short): - np.random.seed(500) - - # Load data, model - X_df, y = vetiver.mock.get_mock_data() - model = vetiver.mock.get_mock_model().fit(X_df, y) - - v = vetiver.VetiverModel(model=model, prototype_data=X_df, model_name="susan/model") - - board = pins.board_rsconnect(server_url=RSC_SERVER_URL, api_key=get_key("susan"), allow_pickle_read=True) - - vetiver.vetiver_pin_write(board=board, model=v) - connect_server = RSConnectServer(url=RSC_SERVER_URL, api_key=get_key("susan")) - - vetiver.deploy_rsconnect( - connect_server=connect_server, - board=board, - pin_name="susan/model", - title="testapivetiver", - extra_files=["requirements.txt"], - ) - - # get url of where content lives - client = RSConnectClient(connect_server) - dicts = client.content_list() - rsc_api = list(filter(lambda x: x["title"] == "testapivetiver", dicts)) - content_url = rsc_api[0].get("content_url") - - h = {"Authorization": "Key {}".format(get_key("susan"))} - - endpoint = vetiver.vetiver_endpoint(content_url + "/predict") - response = vetiver.predict(endpoint, X_df, headers=h) - - assert isinstance(response, pd.DataFrame), response - assert response.iloc[0, 0] == 44.47 - assert len(response) == 100 From cbad60318eb1201b0df7f8603e12cb7f21191eab Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 17:01:18 -0400 Subject: [PATCH 6/9] ci: drop test-dev-connect job (system caches now mocked, vetiver test removed) --- .github/workflows/main.yml | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d56bb7fe..7a30cfdb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -179,33 +179,3 @@ jobs: license: ${{ secrets.CONNECT_LICENSE_FILE }} command: | uv run --no-sync --group test ./scripts/runtests - - test-dev-connect: - name: "Integration tests against dev Connect" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v6 - with: - version: ">=0.9.0" - - uses: extractions/setup-just@v3 - - name: Install dependencies - run: | - uv sync --python 3.12 --group test - uv pip install -r vetiver-testing/vetiver-requirements.txt - - name: Run Posit Connect - run: | - docker compose up --build -d - uv pip freeze > requirements.txt - just dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Get logs in case of failure - run: | - docker compose logs rsconnect - if: ${{ failure() }} - - name: Run tests - run: | - uv run --no-sync pytest tests/test_main_system_caches.py - uv run --no-sync pytest --vetiver -m 'vetiver' From a24588b148ba4bcbcc26a37317798327449575d2 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 17:05:57 -0400 Subject: [PATCH 7/9] chore: remove vetiver test harness and root docker-compose --- Justfile | 12 -------- docker-compose.yml | 15 ---------- vetiver-testing/setup-rsconnect/add-users.sh | 1 - .../setup-rsconnect/dump_api_keys.py | 21 -------------- .../setup-rsconnect/rstudio-connect.gcfg | 29 ------------------- vetiver-testing/setup-rsconnect/users.txt | 4 --- vetiver-testing/vetiver-requirements.txt | 6 ---- 7 files changed, 88 deletions(-) delete mode 100644 docker-compose.yml delete mode 100644 vetiver-testing/setup-rsconnect/add-users.sh delete mode 100644 vetiver-testing/setup-rsconnect/dump_api_keys.py delete mode 100644 vetiver-testing/setup-rsconnect/rstudio-connect.gcfg delete mode 100644 vetiver-testing/setup-rsconnect/users.txt delete mode 100644 vetiver-testing/vetiver-requirements.txt diff --git a/Justfile b/Justfile index cb4358c7..33c9ac55 100644 --- a/Justfile +++ b/Justfile @@ -60,18 +60,6 @@ clean-stores: set -euo pipefail find . -name "rsconnect-python" -o -name "rsconnect_python-*" -o -name "rsconnect-*" | xargs rm -rf -# Start a local Connect server for development (Docker; not replaced by uv) -dev: - docker compose up -d - sleep 30 - docker compose exec -T rsconnect bash < vetiver-testing/setup-rsconnect/add-users.sh - uv run python vetiver-testing/setup-rsconnect/dump_api_keys.py vetiver-testing/rsconnect_api_keys.json - -# Stop the local Connect server -dev-stop: - docker compose down - rm -f vetiver-testing/rsconnect_api_keys.json - # Sync latest docs to S3 (CI) sync-latest-docs-to-s3: aws s3 sync --acl bucket-owner-full-control --cache-control max-age=0 site/ s3://rstudio-connect-downloads/connect/rsconnect-python/latest/docs/ diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 9f5f2d50..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - - rsconnect: - image: rstudio/rstudio-connect-preview:dev-jammy-daily - restart: always - ports: - - 3939:3939 - volumes: - - $PWD/vetiver-testing/setup-rsconnect/users.txt:/etc/users.txt - - $PWD/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg:/etc/rstudio-connect/rstudio-connect.gcfg - # by default, mysql rounds to 4 decimals, but tests require more precision - privileged: true - environment: - RSTUDIO_CONNECT_HASTE: "enabled" - RSC_LICENSE: ${RSC_LICENSE} diff --git a/vetiver-testing/setup-rsconnect/add-users.sh b/vetiver-testing/setup-rsconnect/add-users.sh deleted file mode 100644 index 1df8c7f4..00000000 --- a/vetiver-testing/setup-rsconnect/add-users.sh +++ /dev/null @@ -1 +0,0 @@ -awk ' { system("useradd -m -s /bin/bash "$1); system("echo \""$1":"$2"\" | chpasswd"); system("id "$1) } ' /etc/users.txt diff --git a/vetiver-testing/setup-rsconnect/dump_api_keys.py b/vetiver-testing/setup-rsconnect/dump_api_keys.py deleted file mode 100644 index eebef59f..00000000 --- a/vetiver-testing/setup-rsconnect/dump_api_keys.py +++ /dev/null @@ -1,21 +0,0 @@ -import json -import sys - -from pins.rsconnect.api import _HackyConnect - -OUT_FILE = sys.argv[1] - - -def get_api_key(user, password, email): - rsc = _HackyConnect("http://localhost:3939") - - return rsc.create_first_admin(user, password, email).api_key - - -api_keys = { - "admin": get_api_key("admin", "admin0", "admin@example.com"), - "susan": get_api_key("susan", "susan", "susan@example.com"), - "derek": get_api_key("derek", "derek", "derek@example.com"), -} - -json.dump(api_keys, open(OUT_FILE, "w")) diff --git a/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg b/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg deleted file mode 100644 index fb58655f..00000000 --- a/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg +++ /dev/null @@ -1,29 +0,0 @@ -[Server] -DataDir = /data -Address = http://localhost:3939 - -[HTTP] -Listen = :3939 - -[Authentication] -Provider = pam - -[Authorization] -DefaultUserRole = publisher - -[Python] -Enabled = true -Executable = /opt/python/3.12.11/bin/python -Executable = /opt/python/3.11.13/bin/python - -[RPackageRepository "CRAN"] -URL = https://p3m.dev/cran/latest - -[RPackageRepository "RSPM"] -URL = https://p3m.dev/cran/latest - -[R] -PositPackageManagerURLRewriting = force-binary - -[Logging] -ServiceLog = STDOUT diff --git a/vetiver-testing/setup-rsconnect/users.txt b/vetiver-testing/setup-rsconnect/users.txt deleted file mode 100644 index dd4ec359..00000000 --- a/vetiver-testing/setup-rsconnect/users.txt +++ /dev/null @@ -1,4 +0,0 @@ -admin admin0 -test test -susan susan -derek derek diff --git a/vetiver-testing/vetiver-requirements.txt b/vetiver-testing/vetiver-requirements.txt deleted file mode 100644 index 7fb4a023..00000000 --- a/vetiver-testing/vetiver-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pandas -numpy -pydantic<2 -pytest -pins -vetiver From 79fac1100e448f300d69db630b0e23c59693ef2f Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 17:08:39 -0400 Subject: [PATCH 8/9] docs: relabel deploy_python_fastapi as supported vetiver compat shim --- rsconnect/actions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 3130e010..16418088 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -278,10 +278,11 @@ def validate_quarto_engines(inspect: QuartoInspectResult): # =============================================================================== -# START: The following deprecated functions are here only for the vetiver-python -# package. -# Some the code in this section has `pyright: ignore` comments, because this -# deprecated code which will be removed in the future. +# START: Compatibility entry point used by the vetiver-python package. +# vetiver's `deploy_connect` calls `deploy_python_fastapi` (below), which routes +# through `deploy_app` and the local `validate_*` helpers. This is a supported +# shim; keep these signatures stable. The `pyright: ignore` comments remain +# because the kwargs-forwarding style predates strict typing. # =============================================================================== def validate_extra_files(directory: str, extra_files: Sequence[str]): """ @@ -439,7 +440,7 @@ def deploy_app( # =============================================================================== -# END deprecated functions for the vetiver-python package +# END compatibility entry point for the vetiver-python package # =============================================================================== From ce405add9154a69f9961b24bb6814112e064aa12 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 30 Jun 2026 17:20:09 -0400 Subject: [PATCH 9/9] docs: remove superpowers planning docs --- ...26-06-30-rsconnect-drop-vetiver-baggage.md | 327 ------------------ 1 file changed, 327 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md diff --git a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md b/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md deleted file mode 100644 index 835f2fb6..00000000 --- a/docs/superpowers/plans/2026-06-30-rsconnect-drop-vetiver-baggage.md +++ /dev/null @@ -1,327 +0,0 @@ -# rsconnect-python: drop vetiver test baggage — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove all vetiver-specific test code from rsconnect-python, convert its own `system caches` integration test to an offline `httpretty`-mocked unit test (no live Connect), drop the now-empty `test-dev-connect` CI job and the bespoke Connect harness, and relabel the `deploy_python_fastapi` compatibility shim instead of removing it. - -**Architecture:** rsconnect's `system caches list/delete` CLI commands are thin wrappers over `GET`/`DELETE v1/system/caches/runtime`. Connect's *permission enforcement* on that endpoint (admin allowed; publisher/viewer/anon → 403) is already fully covered upstream in `connect/test/api/tests/test_system_caches_runtime.py`, so rsconnect does not need a live Connect or a second user to re-test it. Instead rsconnect tests its own CLI layer — command wiring, required-flag validation, output formatting, and error surfacing — with `httpretty`, the repo's established HTTP-mocking approach. This removes rsconnect's need for `with-connect` entirely. - -**Tech Stack:** Python, pytest, Click (`CliRunner`), httpretty. - -## Global Constraints - -- **No live Connect / no `with-connect` for rsconnect.** Every test in this plan runs offline; the plan is executable without a license or Docker. -- The `system caches` CLI hits `v1/system/caches/runtime` (`rsconnect/api.py:982-988`); mocks must target that path. The CLI builds an `RSConnectExecutor(...).validate_server()` first, so the mock must also satisfy server validation — mirror the httpretty server setup already used in `tests/test_main_content.py` / `tests/test_main_integration.py`. -- Connect permission enforcement is relied upon from upstream `connect/test/api/tests/test_system_caches_runtime.py` (already covers all four roles for these endpoints). Do not re-test enforcement against a live server here. -- The `actions.py` shim (`deploy_python_fastapi` → `deploy_app` → `validate_*`, lines ~281–442) is **kept**. Do not delete it. Do not alter the active `validate_*` in `bundle.py`. -- This branch is based on `uv-tooling-modernization` (its exact-block edits target that branch's `Justfile`/`main.yml`/`conftest.py`), not `main`. -- Keep CI green at every commit: rewrite `test_main_system_caches.py` (Task 1) and remove the job that depended on the old harness (Task 3) **before** deleting `vetiver-testing/` (Task 4). - ---- - -### Task 1: Rewrite `test_main_system_caches.py` as an httpretty-mocked unit test - -**Files:** -- Modify: `tests/test_main_system_caches.py` (full rewrite — remove all live-Connect/docker/JSON-key machinery) - -**Interfaces:** -- Consumes: nothing external (fully mocked). -- Produces: an offline test module that runs in the default `pytest ./tests/` suite (no marker, no skip guard). - -- [ ] **Step 1: Study the existing CLI-against-mocked-Connect pattern** - -Read `tests/test_main_content.py` (and `tests/test_main_integration.py`) for how a `CliRunner().invoke(cli, ...)` flow is run against an `httpretty`-mocked Connect, specifically how server validation is satisfied (the endpoints `RSConnectExecutor.validate_server()` calls — typically the server settings + current-user/verify endpoints). Reuse that exact setup so the executor validates without a real server. Identify the helper or the set of `register_uri` calls needed and report them in your report. - -- [ ] **Step 2: Write the mocked tests** - -Replace the entire contents of `tests/test_main_system_caches.py`. Keep the four behaviors the old test covered, now mocked (no `admin`/`susan` users, no docker, no JSON keys). Use the repo's `@httpretty.activate(verbose=True, allow_net_connect=False)` style and `CliRunner`. The endpoint is `v1/system/caches/runtime`. - -Cover: -1. **`list` happy path** — register `GET v1/system/caches/runtime` returning a caches payload (e.g. `{"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]}`); invoke `system caches list`; assert exit 0 and that stdout JSON matches. -2. **`delete` happy path** — register `DELETE v1/system/caches/runtime` returning success; invoke `system caches delete --language Python --version 1.2.3 --image-name Local`; assert exit 0. -3. **required-flag validation** — invoke `system caches delete` with no flags (and with only `--language`); assert exit code 2 and the `Missing option '--language' / '-l'` / `--version` messages. (No server interaction occurs; flag parsing fails first — these may not even need httpretty.) -4. **permission surfacing** — register the cache endpoint to return `403` with Connect's permission-denied body; invoke the command; assert exit code 1 and that the output contains the permission-denied message. This replaces the old live "publisher is denied" assertion: it verifies the CLI *surfaces* a 403, while Connect's actual enforcement is covered upstream. - -Mirror `tests/test_main_content.py` for the server-validation `register_uri` calls and for `apply_common_args`-style `-s`/`-k`/`--insecure` argument wiring (keep a local `apply_common_args` helper or inline the args). - -- [ ] **Step 3: Run the test offline** - -Run: `uv run pytest tests/test_main_system_caches.py -v` -Expected: all cases PASS with no network access (httpretty `allow_net_connect=False`) and no Docker. - -- [ ] **Step 4: Confirm it is collected by the default suite** - -Run: `uv run pytest --collect-only -q tests/test_main_system_caches.py` -Expected: the new tests are collected with no special marker or `--vetiver`/live-Connect skip. (It will now run as part of `scripts/runtests` on every PR.) - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_main_system_caches.py -git commit -m "test: cover system caches CLI with httpretty mocks instead of a live Connect" -``` - ---- - -### Task 2: Remove the `--vetiver` marker plumbing and the vetiver test - -**Files:** -- Modify: `conftest.py` -- Delete: `tests/test_vetiver_pins.py` -- Modify: `pyproject.toml` (markers + ruff exclude) - -- [ ] **Step 1: Remove the vetiver option/marker logic from `conftest.py`** - -Delete these three functions from `conftest.py`: - -```python -def pytest_addoption(parser): - parser.addoption("--vetiver", action="store_true", default=False, help="run vetiver tests") - - -def pytest_configure(config): - config.addinivalue_line("markers", "vetiver: test for vetiver interaction") - - -def pytest_collection_modifyitems(config, items): - if config.getoption("--vetiver"): - return - skip_vetiver = pytest.mark.skip(reason="need --vetiver option to run") - for item in items: - if "vetiver" in item.keywords: - item.add_marker(skip_vetiver) -``` - -The remaining `conftest.py` keeps the `CONNECT_CONTENT_BUILD_DIR` setup. The `import pytest` line is now unused — remove it too. - -- [ ] **Step 2: Delete the vetiver integration test** - -Run: `git rm tests/test_vetiver_pins.py` - -- [ ] **Step 3: Remove the `vetiver` marker and the `vetiver-testing` ruff exclude from `pyproject.toml`** - -Change line ~74: - -```toml -markers = ["vetiver: tests for vetiver"] -``` - -to an empty list if it becomes empty: - -```toml -markers = [] -``` - -And in the ruff `extend-exclude` (line ~63), drop `"vetiver-testing"`: - -```toml -extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "tests/testdata"] -``` - -- [ ] **Step 4: Verify collection has no orphaned marker references** - -Run: `uv run pytest --collect-only -q 2>&1 | tail -5` -Expected: collection succeeds with no `PytestUnknownMarkWarning: vetiver` and no error about a missing `--vetiver` option. - -- [ ] **Step 5: Commit** - -```bash -git add conftest.py pyproject.toml -git commit -m "test: drop --vetiver marker plumbing and test_vetiver_pins" -``` - ---- - -### Task 3: Remove the `test-dev-connect` CI job - -**Files:** -- Modify: `.github/workflows/main.yml` (delete the `test-dev-connect` job, lines ~183–211) - -The job existed only to run the vetiver test and the old live `test_main_system_caches.py`. With the vetiver test deleted (Task 2) and system caches now an offline unit test that runs in the normal suite (Task 1), the job has nothing left to do. - -- [ ] **Step 1: Delete the entire `test-dev-connect` job** - -Remove the job block (from ` test-dev-connect:` through its final `uv run --no-sync pytest --vetiver -m 'vetiver'` line). Do not add a replacement. The mocked `test_main_system_caches.py` now runs in the standard test job via `scripts/runtests`. - -- [ ] **Step 2: Confirm no other job references the deleted job or the harness** - -Run: `grep -n "test-dev-connect\|vetiver\|just dev\|docker compose" .github/workflows/main.yml` -Expected: no hits (or only unrelated ones you can explain). Confirm no `needs:` in another job points at `test-dev-connect`. - -- [ ] **Step 3: Lint the workflow YAML** - -Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/main.yml'))"` -Expected: no output (valid YAML). - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/main.yml -git commit -m "ci: drop test-dev-connect job (system caches now mocked, vetiver test removed)" -``` - ---- - -### Task 4: Delete the bespoke vetiver harness (vetiver-testing, docker-compose, Justfile dev recipes) - -**Files:** -- Delete: `vetiver-testing/` (entire directory) -- Delete: `docker-compose.yml` (root) -- Modify: `Justfile` (remove `dev` and `dev-stop` recipes) - -With no live Connect test remaining in rsconnect, none of this has a consumer. - -- [ ] **Step 1: Confirm nothing still references these paths** - -Run: -```bash -grep -rn "vetiver-testing\|rsconnect_api_keys\|docker compose\|docker-compose" \ - --include='*.py' --include='*.yml' --include='*.yaml' Justfile conftest.py . \ - | grep -v 'docs/superpowers' | grep -v 'integration-testing/' -``` -Expected: no hits outside `docs/superpowers/` and the unrelated `integration-testing/` tree. - -- [ ] **Step 2: Remove the `dev` and `dev-stop` recipes from `Justfile`** - -Delete these two recipes: - -```just -# Start a local Connect server for development (Docker; not replaced by uv) -dev: - docker compose up -d - sleep 30 - docker compose exec -T rsconnect bash < vetiver-testing/setup-rsconnect/add-users.sh - uv run python vetiver-testing/setup-rsconnect/dump_api_keys.py vetiver-testing/rsconnect_api_keys.json - -# Stop the local Connect server -dev-stop: - docker compose down - rm -f vetiver-testing/rsconnect_api_keys.json -``` - -- [ ] **Step 3: Delete the directories** - -```bash -git rm -r vetiver-testing -git rm docker-compose.yml -``` - -- [ ] **Step 4: Verify the unit suite and `just` recipes are intact** - -Run: `just --list` -Expected: lists recipes with no `dev`/`dev-stop`, no parse error. - -Run: `uv run pytest -q` -Expected: PASS (the full offline suite, now including the mocked `test_main_system_caches.py`). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore: remove vetiver test harness and root docker-compose" -``` - ---- - -### Task 5: Relabel the `actions.py` compatibility shim - -**Files:** -- Modify: `rsconnect/actions.py` (comment block markers at lines ~281–285 and ~441–442; optional `validate_*` swap) - -**Interfaces:** -- Produces: no behavior change for callers; `deploy_python_fastapi`/`deploy_app` remain importable with identical signatures. - -- [ ] **Step 1: Rewrite the opening block comment** - -Replace lines ~281–285: - -```python -# START: The following deprecated functions are here only for the vetiver-python -# package. -# Some the code in this section has `pyright: ignore` comments, because this -# deprecated code which will be removed in the future. -# =============================================================================== -``` - -with: - -```python -# START: Compatibility entry point used by the vetiver-python package. -# vetiver's `deploy_connect` calls `deploy_python_fastapi` (below), which routes -# through `deploy_app` and the local `validate_*` helpers. This is a supported -# shim; keep these signatures stable. The `pyright: ignore` comments remain -# because the kwargs-forwarding style predates strict typing. -# =============================================================================== -``` - -- [ ] **Step 2: Rewrite the closing block comment** - -Replace lines ~441–442: - -```python -# =============================================================================== -# END deprecated functions for the vetiver-python package -# =============================================================================== -``` - -with: - -```python -# =============================================================================== -# END compatibility entry point for the vetiver-python package -# =============================================================================== -``` - -- [ ] **Step 3 (optional cleanup): Stop emitting spurious deprecation warnings on vetiver deploys** - -`deploy_app` calls the local `validate_entry_point`/`validate_extra_files`, which each emit a `DeprecationWarning`. Point it at the active `bundle.py` versions instead. At the top of `actions.py`, confirm/add: - -```python -from .bundle import validate_entry_point as _validate_entry_point -from .bundle import validate_extra_files as _validate_extra_files -``` - -Then in `deploy_app` change lines ~361–362: - -```python - kwargs["entry_point"] = entry_point = validate_entry_point(entry_point, directory) # pyright: ignore - kwargs["extra_files"] = extra_files = validate_extra_files(directory, extra_files) # pyright: ignore -``` - -to: - -```python - kwargs["entry_point"] = entry_point = _validate_entry_point(entry_point, directory) # pyright: ignore - kwargs["extra_files"] = extra_files = _validate_extra_files(directory, extra_files) # pyright: ignore -``` - -Confirm the `bundle.py` signatures match: `validate_entry_point(entry_point, directory)` and `validate_extra_files(directory, extra_files)`. If `bundle.py`'s `validate_extra_files` requires an extra `use_abspath` argument, pass its default explicitly. - -- [ ] **Step 4: Verify the shim still imports and lints** - -```bash -uv run python -c "from rsconnect.actions import deploy_python_fastapi, deploy_app; print('ok')" -just lint -``` -Expected: prints `ok`; `just lint` passes. - -- [ ] **Step 5: Commit** - -```bash -git add rsconnect/actions.py -git commit -m "docs: relabel deploy_python_fastapi as supported vetiver compat shim" -``` - ---- - -## Why no live Connect / no with-connect for rsconnect - -The earlier draft of this plan kept a live Connect test (with a gcfg + `useradd` + key-mint helper) to exercise the publisher-denied path. Investigation showed that path re-tests *Connect's* permission enforcement, which is already covered upstream in `connect/test/api/tests/test_system_caches_runtime.py` for all four roles on the same `v1/system/caches/runtime` endpoint. rsconnect's job is to test its CLI wiring, which `httpretty` mocks do offline. This deletes the spike, the gcfg/helper, and the with-connect CI job, and moves system-caches coverage into the default per-PR suite. (vetiver-python, by contrast, genuinely deploys a model and serves predictions, so it keeps a live `with-connect` test — see rstudio/vetiver-python#242.) - -## Self-Review notes - -- Every task is offline and locally verifiable (no license/Docker). -- Task 1 preserves the CLI behaviors the old test covered (list, delete, required-flag validation, 403 surfacing) via mocks; real enforcement is covered upstream. -- Ordering keeps CI green: system-caches rewritten (Task 1) and the dependent job removed (Task 3) before the harness is deleted (Task 4). -- The shim is relabeled, never removed (Task 5); `bundle.py` validate functions untouched except as an explicit import in the optional cleanup. -- No external action pin, no with-connect dependency anywhere in this plan.