Skip to content

Commit 5fa320b

Browse files
redeploy: reuse a saved credential matched by server URL
Publisher keeps its own credentials in VS Code SecretStorage, which a CLI cannot read, and a .posit record stores only server_url (never the key). So redeploy now matches the record's server_url against rsconnect-python's own saved servers (ServerStore) by normalized URL -- the same join Publisher uses -- and deploys under that nickname when the caller gave no explicit credential. Ambiguous matches (>1 saved server for the same URL) raise, asking for --name. Also fixes normalize_url to strip a trailing slash before the /__api__ suffix so '.../__api__/' compares equal to the base URL.
1 parent f3aa10b commit 5fa320b

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

rsconnect/main.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2298,6 +2298,38 @@ def _finish_redeploy(
22982298
ce.activate_deployment().emit_task_log()
22992299

23002300

2301+
def _find_saved_server_by_url(server_url: Optional[str]) -> Optional[dict[str, Any]]:
2302+
"""Find a saved rsconnect-python server whose URL matches ``server_url`` (normalized).
2303+
2304+
A ``.posit`` deployment record stores only the ``server_url`` (never the API
2305+
key), and Publisher keeps its own credentials in VS Code SecretStorage, which
2306+
a CLI cannot read. So to redeploy without re-specifying credentials, we match
2307+
the record's server against a credential the user already saved with
2308+
rsconnect-python -- using the same normalized-URL comparison Publisher uses to
2309+
join a record to a credential.
2310+
2311+
Returns the single match, or ``None`` if none match. Raises when more than one
2312+
saved server matches (e.g. two credentials for the same URL under different
2313+
nicknames), since guessing which credential to use would be unsafe -- the user
2314+
disambiguates with ``--name``.
2315+
"""
2316+
if not server_url:
2317+
return None
2318+
target = publisher_normalize_url(server_url)
2319+
matches = [
2320+
entry
2321+
for entry in server_store.get_all_servers()
2322+
if entry.get("url") and publisher_normalize_url(entry["url"]) == target
2323+
]
2324+
if len(matches) > 1:
2325+
raise RSConnectException(
2326+
"Multiple saved servers match {} ({}); pick one with --name.".format(
2327+
server_url, ", ".join(sorted(str(m.get("name")) for m in matches))
2328+
)
2329+
)
2330+
return matches[0] if matches else None
2331+
2332+
23012333
def _legacy_records_for_dir(directory: str) -> list[dict[str, Any]]:
23022334
"""Read legacy per-directory deployment records from ``rsconnect-python/*.json``.
23032335
@@ -2369,10 +2401,17 @@ def _redeploy_from_legacy(
23692401
entry = next(iter(by_server.values()))
23702402

23712403
app_mode = read_manifest_app_mode(manifest_path)
2404+
deploy_server = server or entry["server_url"]
2405+
# Reuse a saved rsconnect-python credential matching the recorded server.
2406+
if not name and not server and not api_key:
2407+
matched = _find_saved_server_by_url(entry["server_url"])
2408+
if matched:
2409+
name = matched["name"]
2410+
deploy_server = None
23722411
ce = RSConnectExecutor(
23732412
ctx=ctx,
23742413
name=name,
2375-
server=server or entry["server_url"],
2414+
server=deploy_server,
23762415
api_key=api_key,
23772416
snowflake_connection_name=snowflake_connection_name,
23782417
insecure=insecure,
@@ -2531,6 +2570,13 @@ def redeploy(
25312570
effective_app_id = app_id or target.app_id
25322571
# Deploy to the record's server unless the caller overrode the destination.
25332572
deploy_server = server or target.server_url
2573+
# With no explicit credential, reuse a saved rsconnect-python server whose URL
2574+
# matches the record's (Publisher's own credentials are not reachable here).
2575+
if not name and not server and not api_key:
2576+
matched = _find_saved_server_by_url(target.server_url)
2577+
if matched:
2578+
name = matched["name"]
2579+
deploy_server = None
25342580

25352581
deploy_path, bundle_builder, bundle_args, bundle_kwargs = _plan_deploy_bundle(
25362582
directory,

rsconnect/publisher/store.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def normalize_url(url: str) -> str:
4040
return ""
4141
parsed = urlparse(url if "//" in url else "//" + url)
4242
netloc = parsed.netloc.lower()
43-
path = parsed.path
43+
# Strip trailing slashes first so a trailing slash after ``__api__``
44+
# (".../__api__/") still lets the suffix be removed.
45+
path = parsed.path.rstrip("/")
4446
if path.endswith("/__api__"):
4547
path = path[: -len("/__api__")]
4648
path = path.rstrip("/")

tests/test_redeploy.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,76 @@ def test_redeploy_legacy_manifest_without_record_needs_server(runner: CliRunner,
271271
assert "No prior deployment found" in result.output
272272

273273

274+
def test_find_saved_server_by_url_matches_normalized(monkeypatch: pytest.MonkeyPatch):
275+
from rsconnect import main as main_mod
276+
277+
saved = [{"name": "prod", "url": "https://connect.example.com/__api__"}]
278+
monkeypatch.setattr(main_mod, "server_store", types.SimpleNamespace(get_all_servers=lambda: saved))
279+
280+
# trailing slash / __api__ differences still match
281+
assert main_mod._find_saved_server_by_url("https://connect.example.com/")["name"] == "prod"
282+
assert main_mod._find_saved_server_by_url("https://other.example.com") is None
283+
assert main_mod._find_saved_server_by_url(None) is None
284+
285+
286+
def test_find_saved_server_by_url_ambiguous_raises(monkeypatch: pytest.MonkeyPatch):
287+
"""Two saved credentials for the same server must not be guessed between."""
288+
from rsconnect import main as main_mod
289+
from rsconnect.exception import RSConnectException
290+
291+
saved = [
292+
{"name": "prod-a", "url": "https://connect.example.com"},
293+
{"name": "prod-b", "url": "https://connect.example.com/__api__"},
294+
]
295+
monkeypatch.setattr(main_mod, "server_store", types.SimpleNamespace(get_all_servers=lambda: saved))
296+
297+
with pytest.raises(RSConnectException, match="Multiple saved servers match"):
298+
main_mod._find_saved_server_by_url("https://connect.example.com/")
299+
300+
301+
def test_redeploy_reuses_saved_credential_by_url(
302+
runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
303+
):
304+
"""With no explicit credential, redeploy matches the record's server_url to a
305+
saved rsconnect-python server (normalized) and deploys under that nickname."""
306+
from rsconnect import main as main_mod
307+
308+
saved = {"name": "prod", "url": "https://connect.example.com/__api__/"}
309+
monkeypatch.setattr(
310+
main_mod,
311+
"server_store",
312+
types.SimpleNamespace(get_all_servers=lambda: [saved], get_by_name=lambda n: saved if n == "prod" else None),
313+
)
314+
315+
captured: dict[str, typing.Any] = {}
316+
317+
class FakeExecutor:
318+
def __init__(self, **kwargs: typing.Any):
319+
captured.update(kwargs)
320+
self.client = None
321+
self.supports_verify_before_activate = False
322+
323+
def __getattr__(self, _name: str):
324+
# every fluent step is a no-op that returns self
325+
return lambda *a, **k: self
326+
327+
def should_deploy_as_draft(self, *a: typing.Any, **k: typing.Any) -> bool:
328+
return False
329+
330+
monkeypatch.setattr(main_mod, "RSConnectExecutor", FakeExecutor)
331+
monkeypatch.setattr(main_mod, "prepare_deploy_metadata", lambda *a, **k: None)
332+
fake_env = types.SimpleNamespace(python="python")
333+
monkeypatch.setattr(main_mod.Environment, "create_python_environment", classmethod(lambda cls, *a, **k: fake_env))
334+
_write_posit_project(project_dir) # record server_url = https://connect.example.com
335+
336+
result = runner.invoke(cli, ["redeploy", str(project_dir)])
337+
338+
assert result.exit_code == 0, result.output
339+
# matched the saved server: deploy under its nickname, no raw server URL
340+
assert captured.get("name") == "prod"
341+
assert captured.get("server") is None
342+
343+
274344
def test_redeploy_dispatches_quarto(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
275345
captured = _spy_make_bundle(monkeypatch)
276346
from rsconnect import main as main_mod

0 commit comments

Comments
 (0)