mirror of
https://github.com/run-llama/workflows-py.git
synced 2026-07-20 19:57:02 -04:00
Fix deployment save auth recovery (#632)
The github auth flow was lost/overlooked during the refactor to use $EDITOR. This restores the functionality, waiting for auth and opening a browser.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"llamactl": patch
|
||||
---
|
||||
|
||||
Fix inline auth recovery while saving deployments.
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||
|
||||
import click
|
||||
from llama_agents.cli.env_settings import LlamactlEnvSettings, read_env_settings
|
||||
from llama_agents.cli.interactive import is_interactive_session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from llama_agents.core.client.manage_client import ControlPlaneClient, ProjectClient
|
||||
@@ -157,6 +158,21 @@ def get_project_client(project_id_override: str | None = None) -> ProjectClient:
|
||||
context.auth_middleware,
|
||||
)
|
||||
|
||||
if is_interactive_session():
|
||||
# Deferred: auth command imports browser/prompt dependencies and also
|
||||
# depends on client helpers for project discovery.
|
||||
from llama_agents.cli.commands.auth import validate_authenticated_profile
|
||||
|
||||
validate_authenticated_profile()
|
||||
context = _auth_context_or_none(project_id_override)
|
||||
if context is not None:
|
||||
return ProjectClient(
|
||||
context.base_url,
|
||||
context.project_id,
|
||||
context.api_key,
|
||||
context.auth_middleware,
|
||||
)
|
||||
|
||||
from llama_agents.cli.config.env_service import service
|
||||
|
||||
auth_svc = service.current_auth_service()
|
||||
|
||||
@@ -31,6 +31,7 @@ from llama_agents.core.schema.deployments import (
|
||||
DeploymentResponse,
|
||||
DeploymentUpdate,
|
||||
)
|
||||
from llama_agents.core.schema.git_validation import RepositoryValidationResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..app import app
|
||||
@@ -204,6 +205,210 @@ def _repository_error_path(
|
||||
return ()
|
||||
|
||||
|
||||
def _github_app_access_url(vr: RepositoryValidationResponse) -> str | None:
|
||||
return vr.github_app_settings_url or vr.github_app_installation_url
|
||||
|
||||
|
||||
def _is_github_app_connect_url(url: str | None) -> bool:
|
||||
return (
|
||||
url is not None
|
||||
and "/api/internal/external-credentials/github-app/connect" in url
|
||||
)
|
||||
|
||||
|
||||
def _github_app_authorization_url(vr: RepositoryValidationResponse) -> str | None:
|
||||
if vr.github_app_authorization_url:
|
||||
return vr.github_app_authorization_url
|
||||
if _is_github_app_connect_url(vr.github_app_installation_url):
|
||||
return vr.github_app_installation_url
|
||||
return None
|
||||
|
||||
|
||||
def _github_app_recovery_url(vr: RepositoryValidationResponse) -> str | None:
|
||||
return _github_app_authorization_url(vr) or _github_app_access_url(vr)
|
||||
|
||||
|
||||
def _repository_validation_error_message(vr: RepositoryValidationResponse) -> str:
|
||||
message = vr.message
|
||||
authorization_url = _github_app_authorization_url(vr)
|
||||
if authorization_url:
|
||||
message += f"\n\nConnect GitHub: {authorization_url}"
|
||||
else:
|
||||
url = _github_app_access_url(vr)
|
||||
if url:
|
||||
message += f"\n\nInstall the GitHub App: {url}"
|
||||
return message
|
||||
|
||||
|
||||
class _GitHubCallbackServer:
|
||||
"""Local callback server for GitHub OAuth/App redirects."""
|
||||
|
||||
def __init__(self, port: int = 41010) -> None:
|
||||
self.port = port
|
||||
self.callback_received = asyncio.Event()
|
||||
self.app: Any | None = None
|
||||
self.runner: Any | None = None
|
||||
self.site: Any | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
# Deferred: aiohttp is only needed for the browser callback path.
|
||||
from aiohttp.web_app import Application
|
||||
from aiohttp.web_response import Response
|
||||
from aiohttp.web_runner import AppRunner, TCPSite
|
||||
|
||||
async def _handle_callback(_: Any) -> Response:
|
||||
self.callback_received.set()
|
||||
return Response(text=self._success_html(), content_type="text/html")
|
||||
|
||||
self.app = Application()
|
||||
self.app.router.add_get("/", _handle_callback)
|
||||
self.app.router.add_get("/{path:.*}", _handle_callback)
|
||||
self.runner = AppRunner(self.app, logger=None)
|
||||
await self.runner.setup()
|
||||
self.site = TCPSite(self.runner, "localhost", self.port)
|
||||
await self.site.start()
|
||||
|
||||
async def wait(self, timeout: float) -> None:
|
||||
try:
|
||||
await asyncio.wait_for(self.callback_received.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"GitHub callback timed out after {timeout} seconds")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.site is not None:
|
||||
await self.site.stop()
|
||||
self.site = None
|
||||
if self.runner is not None:
|
||||
await self.runner.cleanup()
|
||||
self.runner = None
|
||||
self.app = None
|
||||
self.callback_received.clear()
|
||||
|
||||
def _success_html(self) -> str:
|
||||
return (
|
||||
"<!DOCTYPE html>"
|
||||
"<html>"
|
||||
"<head><title>llamactl - Authentication Complete</title></head>"
|
||||
"<body>"
|
||||
"<h1>Authentication complete</h1>"
|
||||
"<p>Return to your terminal to continue.</p>"
|
||||
"</body>"
|
||||
"</html>"
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_callback_or_interval(
|
||||
server: _GitHubCallbackServer,
|
||||
interval: int,
|
||||
) -> bool:
|
||||
callback_task = asyncio.create_task(server.wait(timeout=300))
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(interval))
|
||||
done, pending = await asyncio.wait(
|
||||
{callback_task, sleep_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
task.result()
|
||||
return callback_task in done
|
||||
|
||||
|
||||
async def _open_github_url_and_poll_access(
|
||||
client: Any,
|
||||
*,
|
||||
url: str,
|
||||
wait_message: str,
|
||||
repo_url: str,
|
||||
deployment_id: str | None,
|
||||
pat: str | None,
|
||||
) -> RepositoryValidationResponse:
|
||||
# Deferred: browser launch support is only needed on this auth recovery path.
|
||||
import webbrowser
|
||||
|
||||
server = _GitHubCallbackServer()
|
||||
await server.start()
|
||||
try:
|
||||
click.echo(f"Open this URL: {url}", err=True)
|
||||
webbrowser.open(url)
|
||||
|
||||
interval = 10
|
||||
elapsed = 0
|
||||
while True:
|
||||
callback_received = await _wait_for_callback_or_interval(server, interval)
|
||||
if not callback_received:
|
||||
elapsed += interval
|
||||
click.echo(
|
||||
f"\r● {wait_message}... ({elapsed}s)",
|
||||
nl=False,
|
||||
err=True,
|
||||
)
|
||||
vr = await client.validate_repository(
|
||||
repo_url=repo_url,
|
||||
deployment_id=deployment_id,
|
||||
pat=pat,
|
||||
)
|
||||
if vr.accessible:
|
||||
click.echo(err=True)
|
||||
return vr
|
||||
if callback_received:
|
||||
return vr
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
async def _resolve_github_app_access(
|
||||
client: Any,
|
||||
vr: RepositoryValidationResponse,
|
||||
repo_url: str,
|
||||
deployment_id: str | None,
|
||||
pat: str | None,
|
||||
) -> RepositoryValidationResponse:
|
||||
"""Open GitHub App access URL and poll until repo validation succeeds."""
|
||||
authorization_url = _github_app_authorization_url(vr)
|
||||
install_url = _github_app_access_url(vr)
|
||||
if authorization_url == install_url:
|
||||
install_url = None
|
||||
if authorization_url is None and install_url is None:
|
||||
return vr
|
||||
|
||||
app_name = vr.github_app_name or "configured"
|
||||
click.echo(
|
||||
f"GitHub App '{app_name}' does not have access to this repository.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if authorization_url is not None:
|
||||
click.echo("Opening browser to connect GitHub...", err=True)
|
||||
vr = await _open_github_url_and_poll_access(
|
||||
client,
|
||||
url=authorization_url,
|
||||
wait_message="waiting for GitHub authorization",
|
||||
repo_url=repo_url,
|
||||
deployment_id=deployment_id,
|
||||
pat=pat,
|
||||
)
|
||||
if vr.accessible:
|
||||
return vr
|
||||
install_url = _github_app_access_url(vr)
|
||||
|
||||
if install_url is None:
|
||||
return vr
|
||||
|
||||
click.echo(
|
||||
"Opening browser to install the GitHub App... (press Ctrl+C to cancel)",
|
||||
err=True,
|
||||
)
|
||||
return await _open_github_url_and_poll_access(
|
||||
client,
|
||||
url=install_url,
|
||||
wait_message="waiting for GitHub App installation",
|
||||
repo_url=repo_url,
|
||||
deployment_id=deployment_id,
|
||||
pat=pat,
|
||||
)
|
||||
|
||||
|
||||
def _read_apply_input(filename: str) -> str:
|
||||
if filename == "-":
|
||||
return click.get_text_stream("stdin").read()
|
||||
@@ -244,7 +449,9 @@ def _raise_apply_yaml_click_error(exc: ApplyYamlError) -> NoReturn:
|
||||
|
||||
|
||||
def _new_deployment_template_yaml(
|
||||
*, action_hint: str = "Edit, then run: llamactl deployments apply -f <file>"
|
||||
*,
|
||||
action_hint: str = "Edit, then run: llamactl deployments apply -f <file>",
|
||||
logged_in_email: str | None = None,
|
||||
) -> str:
|
||||
ctx = gather_local_context()
|
||||
|
||||
@@ -272,7 +479,10 @@ def _new_deployment_template_yaml(
|
||||
|
||||
display = DeploymentDisplay(name=None, generate_name=preferred_name, spec=spec)
|
||||
|
||||
head: list[str] = [f"WARNING: {warning}" for warning in ctx.warnings]
|
||||
head: list[str] = []
|
||||
if logged_in_email:
|
||||
head.append(f"Logged in as {logged_in_email}")
|
||||
head.extend(f"WARNING: {warning}" for warning in ctx.warnings)
|
||||
if ctx.warnings:
|
||||
head.append("")
|
||||
head.append(action_hint)
|
||||
@@ -652,9 +862,17 @@ def create_deployment(
|
||||
if _requires_file_for_editor():
|
||||
raise click.ClickException("pass -f <file> for non-interactive create")
|
||||
|
||||
# Deferred: auth command imports browser/prompt dependencies and client.py
|
||||
# imports this command module indirectly during inline auth.
|
||||
from llama_agents.cli.commands.auth import validate_authenticated_profile
|
||||
|
||||
profile = validate_authenticated_profile()
|
||||
logged_in_email = profile.device_oidc.email if profile.device_oidc else None
|
||||
|
||||
_edit_deployment_yaml_loop(
|
||||
initial_yaml=_new_deployment_template_yaml(
|
||||
action_hint="Edit, save, and close to create the deployment"
|
||||
action_hint="Edit, save, and close to create the deployment",
|
||||
logged_in_email=logged_in_email,
|
||||
),
|
||||
project=project,
|
||||
no_push=no_push,
|
||||
@@ -904,8 +1122,18 @@ async def _execute_deployment_operation(
|
||||
pat=display.spec.personal_access_token,
|
||||
)
|
||||
if not vr.accessible:
|
||||
if _github_app_recovery_url(vr) and is_interactive_session():
|
||||
vr = await _resolve_github_app_access(
|
||||
client,
|
||||
vr,
|
||||
repo_url,
|
||||
existing.id if existing else None,
|
||||
display.spec.personal_access_token,
|
||||
)
|
||||
if not vr.accessible:
|
||||
message = _repository_validation_error_message(vr)
|
||||
raise RepositoryValidationError(
|
||||
vr.message, _repository_error_path(vr.message, display)
|
||||
message, _repository_error_path(message, display)
|
||||
)
|
||||
|
||||
# Push ordering matrix:
|
||||
|
||||
@@ -99,8 +99,9 @@ def test_client_requires_profile_with_project() -> None:
|
||||
_close_client(client)
|
||||
|
||||
|
||||
def test_client_requires_valid_profile() -> None:
|
||||
def test_client_requires_valid_profile(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that client fails when no profile is configured"""
|
||||
monkeypatch.setattr(client_module, "is_interactive_session", lambda: False)
|
||||
with patch("llama_agents.cli.config.env_service.service") as mock_service:
|
||||
mock_auth_svc = MagicMock()
|
||||
mock_auth_svc.get_current_profile.return_value = None
|
||||
@@ -109,6 +110,34 @@ def test_client_requires_valid_profile() -> None:
|
||||
get_project_client()
|
||||
|
||||
|
||||
def test_interactive_project_client_authenticates_and_retries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile(project_id="authed-project", api_key="authed-key")
|
||||
monkeypatch.setattr(client_module, "is_interactive_session", lambda: True)
|
||||
with (
|
||||
patch("llama_agents.cli.config.env_service.service") as mock_service,
|
||||
patch(
|
||||
"llama_agents.cli.commands.auth.validate_authenticated_profile",
|
||||
return_value=profile,
|
||||
) as validate_authenticated_profile,
|
||||
):
|
||||
mock_auth_svc = MagicMock()
|
||||
mock_auth_svc.get_current_profile.side_effect = [None, profile]
|
||||
mock_auth_svc.auth_middleware.return_value = None
|
||||
mock_service.current_auth_service.return_value = mock_auth_svc
|
||||
|
||||
client = get_project_client()
|
||||
|
||||
try:
|
||||
validate_authenticated_profile.assert_called_once_with()
|
||||
assert client.base_url == "http://test:8011"
|
||||
assert client.project_id == "authed-project"
|
||||
assert client.api_key == "authed-key"
|
||||
finally:
|
||||
_close_client(client)
|
||||
|
||||
|
||||
def test_env_var_project_client_uses_default_base_url_and_api_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -198,6 +227,7 @@ def test_incomplete_env_var_project_client_without_profile_warns_and_exits(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
set_llama_cloud_env(monkeypatch, api_key="env-api-key")
|
||||
monkeypatch.setattr(client_module, "is_interactive_session", lambda: False)
|
||||
_set_current_profile(monkeypatch, None)
|
||||
|
||||
with pytest.raises(click.ClickException, match="No profile configured"):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import textwrap
|
||||
from collections.abc import Generator
|
||||
@@ -24,6 +25,7 @@ from conftest import (
|
||||
set_llama_cloud_env,
|
||||
)
|
||||
from llama_agents.cli.app import app
|
||||
from llama_agents.cli.commands import deployment as deployment_cmd
|
||||
from llama_agents.core.schema.deployments import DeploymentResponse
|
||||
from llama_agents.core.schema.git_validation import RepositoryValidationResponse
|
||||
|
||||
@@ -137,6 +139,43 @@ MINIMAL_UPDATE_YAML = textwrap.dedent("""\
|
||||
git_ref: v2
|
||||
""")
|
||||
|
||||
GITHUB_APP_INSTALL_URL = (
|
||||
"https://github.com/apps/llamaindex/installations/new/permissions?target_id=42"
|
||||
)
|
||||
GITHUB_APP_SETTINGS_URL = "https://github.com/settings/installations/42"
|
||||
GITHUB_APP_AUTHORIZATION_URL = (
|
||||
"https://api.example.test/api/internal/external-credentials/github-app/connect"
|
||||
)
|
||||
|
||||
|
||||
def _repository_validation_response(
|
||||
*,
|
||||
accessible: bool,
|
||||
message: str,
|
||||
github_app_installation_url: str | None = None,
|
||||
github_app_settings_url: str | None = None,
|
||||
github_app_authorization_url: str | None = None,
|
||||
) -> RepositoryValidationResponse:
|
||||
return RepositoryValidationResponse(
|
||||
accessible=accessible,
|
||||
message=message,
|
||||
github_app_name="llamaindex",
|
||||
github_app_installation_url=github_app_installation_url,
|
||||
github_app_settings_url=github_app_settings_url,
|
||||
github_app_authorization_url=github_app_authorization_url,
|
||||
)
|
||||
|
||||
|
||||
class _FakeGitHubCallbackServer:
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def wait(self, timeout: float) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_apply_creates_when_not_found(patched_auth: Any, tmp_path: Any) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -418,6 +457,255 @@ def test_apply_validate_repository_blocks_create(
|
||||
client.create_deployment.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_interactive_inaccessible_github_repo_installs_app_and_retries(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock(created=make_deployment("new-app"))
|
||||
client.validate_repository = AsyncMock(
|
||||
side_effect=[
|
||||
_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub App does not have access",
|
||||
github_app_installation_url=GITHUB_APP_INSTALL_URL,
|
||||
),
|
||||
_repository_validation_response(accessible=True, message="ok"),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
|
||||
) as mock_wait,
|
||||
):
|
||||
mock_wait.return_value = False
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_open.assert_called_once_with(GITHUB_APP_INSTALL_URL)
|
||||
assert f"Open this URL: {GITHUB_APP_INSTALL_URL}" in result.output
|
||||
mock_wait.assert_awaited_once()
|
||||
assert client.validate_repository.await_count == 2
|
||||
client.create_deployment.assert_called_once()
|
||||
assert "created new-app" in result.output
|
||||
|
||||
|
||||
def test_apply_non_interactive_inaccessible_github_repo_error_includes_install_url(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock()
|
||||
client.validate_repository = AsyncMock(
|
||||
return_value=_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub App does not have access",
|
||||
github_app_installation_url=GITHUB_APP_INSTALL_URL,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=False),
|
||||
):
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "GitHub App does not have access" in result.output
|
||||
assert GITHUB_APP_INSTALL_URL in result.output
|
||||
client.create_deployment.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_inaccessible_github_repo_without_install_url_preserves_error(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock()
|
||||
client.validate_repository = AsyncMock(
|
||||
return_value=_repository_validation_response(
|
||||
accessible=False,
|
||||
message="repo not found",
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
):
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "repo not found" in result.output
|
||||
assert "Install the GitHub App" not in result.output
|
||||
mock_open.assert_not_called()
|
||||
client.create_deployment.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_interactive_github_app_settings_url_preferred_over_install_url(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock(created=make_deployment("new-app"))
|
||||
client.validate_repository = AsyncMock(
|
||||
side_effect=[
|
||||
_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub App does not have access",
|
||||
github_app_installation_url=GITHUB_APP_INSTALL_URL,
|
||||
github_app_settings_url=GITHUB_APP_SETTINGS_URL,
|
||||
),
|
||||
_repository_validation_response(accessible=True, message="ok"),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
|
||||
) as mock_wait,
|
||||
):
|
||||
mock_wait.return_value = False
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_open.assert_called_once_with(GITHUB_APP_SETTINGS_URL)
|
||||
client.create_deployment.assert_called_once()
|
||||
|
||||
|
||||
def test_apply_interactive_github_authorization_runs_before_install(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock(created=make_deployment("new-app"))
|
||||
client.validate_repository = AsyncMock(
|
||||
side_effect=[
|
||||
_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub user authorization required",
|
||||
github_app_authorization_url=GITHUB_APP_AUTHORIZATION_URL,
|
||||
),
|
||||
_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub App does not have access",
|
||||
github_app_installation_url=GITHUB_APP_INSTALL_URL,
|
||||
),
|
||||
_repository_validation_response(accessible=True, message="ok"),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
|
||||
) as mock_wait,
|
||||
):
|
||||
mock_wait.side_effect = [True, False]
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert [call.args[0] for call in mock_open.call_args_list] == [
|
||||
GITHUB_APP_AUTHORIZATION_URL,
|
||||
GITHUB_APP_INSTALL_URL,
|
||||
]
|
||||
assert f"Open this URL: {GITHUB_APP_AUTHORIZATION_URL}" in result.output
|
||||
assert f"Open this URL: {GITHUB_APP_INSTALL_URL}" in result.output
|
||||
assert client.validate_repository.await_count == 3
|
||||
client.create_deployment.assert_called_once()
|
||||
|
||||
|
||||
def test_apply_interactive_legacy_connect_url_treated_as_authorization(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text(MINIMAL_CREATE_YAML)
|
||||
|
||||
client = _apply_client_mock(created=make_deployment("new-app"))
|
||||
client.validate_repository = AsyncMock(
|
||||
side_effect=[
|
||||
_repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub user authorization required",
|
||||
github_app_installation_url=GITHUB_APP_AUTHORIZATION_URL,
|
||||
),
|
||||
_repository_validation_response(accessible=True, message="ok"),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
|
||||
) as mock_wait,
|
||||
):
|
||||
mock_wait.return_value = True
|
||||
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_open.assert_called_once_with(GITHUB_APP_AUTHORIZATION_URL)
|
||||
assert "Opening browser to connect GitHub" in result.output
|
||||
assert "Opening browser to install the GitHub App" not in result.output
|
||||
client.create_deployment.assert_called_once()
|
||||
|
||||
|
||||
async def test_github_app_poll_cancelled_error_exits() -> None:
|
||||
client = MagicMock()
|
||||
client.validate_repository = AsyncMock()
|
||||
vr = _repository_validation_response(
|
||||
accessible=False,
|
||||
message="GitHub App does not have access",
|
||||
github_app_installation_url=GITHUB_APP_INSTALL_URL,
|
||||
)
|
||||
|
||||
with (
|
||||
pytest.raises(asyncio.CancelledError),
|
||||
patch("webbrowser.open") as mock_open,
|
||||
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
|
||||
) as mock_wait,
|
||||
):
|
||||
mock_wait.side_effect = asyncio.CancelledError
|
||||
await deployment_cmd._resolve_github_app_access(
|
||||
client,
|
||||
vr,
|
||||
"https://github.com/example/repo",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
mock_open.assert_called_once_with(GITHUB_APP_INSTALL_URL)
|
||||
client.validate_repository.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_validates_payload_before_repository(
|
||||
patched_auth: Any, tmp_path: Any
|
||||
) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ import textwrap
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -156,6 +157,50 @@ def test_create_opens_editor_with_template_and_applies_saved_yaml(
|
||||
assert "created editor-app" in result.output
|
||||
|
||||
|
||||
def test_create_preflights_auth_and_shows_logged_in_email(
|
||||
patched_auth: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_local_context(monkeypatch)
|
||||
runner = CliRunner()
|
||||
client = _editor_client_mock(created=make_deployment("editor-app"))
|
||||
saved_text = textwrap.dedent("""\
|
||||
generate_name: Editor App
|
||||
spec:
|
||||
repo_url: https://github.com/example/repo
|
||||
""")
|
||||
order: list[str] = []
|
||||
profile = SimpleNamespace(
|
||||
device_oidc=SimpleNamespace(email="user@example.com"),
|
||||
)
|
||||
|
||||
def _validate_profile() -> SimpleNamespace:
|
||||
order.append("auth")
|
||||
return profile
|
||||
|
||||
def _open_editor(text: str) -> str:
|
||||
order.append("editor")
|
||||
assert "## Logged in as user@example.com" in text
|
||||
return saved_text
|
||||
|
||||
with (
|
||||
patch_project_client(client),
|
||||
patch(
|
||||
"llama_agents.cli.commands.auth.validate_authenticated_profile",
|
||||
side_effect=_validate_profile,
|
||||
) as validate_authenticated_profile,
|
||||
patch(
|
||||
f"{_DEPLOY_CMD}._open_deployment_yaml_editor",
|
||||
side_effect=_open_editor,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["deployments", "create"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert order == ["auth", "editor"]
|
||||
validate_authenticated_profile.assert_called_once_with()
|
||||
client.create_deployment.assert_called_once()
|
||||
|
||||
|
||||
def test_create_file_applies_without_editor_and_threads_project_no_push(
|
||||
patched_auth: Any, tmp_path: Path
|
||||
) -> None:
|
||||
@@ -191,6 +236,34 @@ def test_create_file_applies_without_editor_and_threads_project_no_push(
|
||||
client.create_deployment.assert_called_once()
|
||||
|
||||
|
||||
def test_create_file_without_profile_raises_auth_error(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
f = tmp_path / "deploy.yaml"
|
||||
f.write_text("generate_name: File App\nspec:\n repo_url: ''\n")
|
||||
|
||||
mock_auth_svc = MagicMock()
|
||||
mock_auth_svc.get_current_profile.return_value = None
|
||||
mock_auth_svc.list_profiles.return_value = []
|
||||
mock_auth_svc.env = SimpleNamespace(requires_auth=True)
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.current_auth_service.return_value = mock_auth_svc
|
||||
|
||||
with (
|
||||
patch("llama_agents.cli.config.env_service.service", mock_service),
|
||||
patch("llama_agents.cli.client.is_interactive_session", return_value=False),
|
||||
patch(_INTERACTIVE_PATCH, return_value=False),
|
||||
):
|
||||
result = runner.invoke(app, ["deployments", "create", "-f", str(f)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "No profile configured. To get started, run: llamactl auth login" in (
|
||||
result.output
|
||||
)
|
||||
|
||||
|
||||
def test_create_file_uses_create_intent_not_upsert(
|
||||
patched_auth: Any, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user