diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 3fb70800..78746809 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -9,11 +9,14 @@ from .commands.setup_ci import setup_ci from .commands.unlink import unlink from .commands.whoami import whoami +from .context import ctx from .logging import setup_logging from .utils.sentry import init_sentry setup_logging() +COMMANDS_USE_TOKEN = {"deploy"} + app = typer.Typer(rich_markup_mode="rich") cloud_app = typer.Typer( @@ -21,6 +24,15 @@ help="Manage [bold]FastAPI[/bold] Cloud deployments. 🚀", ) + +@cloud_app.callback() +def cloud_callback(typer_ctx: typer.Context) -> None: + if typer_ctx.invoked_subcommand in COMMANDS_USE_TOKEN: + ctx.initialize(prefer_auth_mode="token") + else: + ctx.initialize() + + # TODO: use the app structure # Additional commands diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 44cdc13c..3ffc4bda 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -19,6 +19,7 @@ from rich_toolkit.menu import Option from fastapi_cloud_cli.commands.login import login +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( SUCCESSFUL_STATUSES, APIClient, @@ -27,7 +28,6 @@ TooManyRetriesError, ) from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -652,7 +652,11 @@ def deploy( "Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id ) - identity = Identity() + # Duplicate context initialization here to make `fastapi deploy` command work + # (callback doesn't take effect in this case) + ctx.initialize(prefer_auth_mode="token") + + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): @@ -688,6 +692,13 @@ def deploy( _waitlist_form(toolkit) raise typer.Exit(1) + if identity.auth_mode == "token": + toolkit.print( + "Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable", + tag="info", + ) + toolkit.print_line() + toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() diff --git a/src/fastapi_cloud_cli/commands/env.py b/src/fastapi_cloud_cli/commands/env.py index b9694dcd..9ccb038c 100644 --- a/src/fastapi_cloud_cli/commands/env.py +++ b/src/fastapi_cloud_cli/commands/env.py @@ -5,9 +5,9 @@ import typer from pydantic import BaseModel +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import get_app_config -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors from fastapi_cloud_cli.utils.env import validate_environment_variable_name @@ -70,7 +70,7 @@ def list( List the environment variables for the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): @@ -125,7 +125,7 @@ def delete( Delete an environment variable from the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): @@ -218,7 +218,7 @@ def set( Set an environment variable for the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/link.py b/src/fastapi_cloud_cli/commands/link.py index ffdba6e1..f7b533ab 100644 --- a/src/fastapi_cloud_cli/commands/link.py +++ b/src/fastapi_cloud_cli/commands/link.py @@ -5,9 +5,9 @@ import typer from rich_toolkit.menu import Option +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -17,7 +17,7 @@ def link() -> Any: """ Link a local directory to an existing FastAPI Cloud app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 6c6ef07b..6030b175 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -7,8 +7,9 @@ from pydantic import BaseModel from fastapi_cloud_cli.config import Settings +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import APIClient -from fastapi_cloud_cli.utils.auth import AuthConfig, Identity, write_auth_config +from fastapi_cloud_cli.utils.auth import AuthConfig, write_auth_config from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -76,7 +77,12 @@ def login() -> Any: """ Login to FastAPI Cloud. 🚀 """ - identity = Identity() + + # Duplicate context initialization here to make `fastapi login` command work + # (callback doesn't take effect in this case) + ctx.initialize() + + identity = ctx.get_identity() if identity.is_logged_in(): with get_rich_toolkit(minimal=True) as toolkit: @@ -87,6 +93,17 @@ def login() -> Any: return + if identity.deploy_token is not None: + with get_rich_toolkit() as toolkit: + toolkit.print( + ( + "You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n" + + "This token will take precedence over the user token for " + + "[blue]`fastapi deploy`[/] command." + ), + tag="Warning", + ) + with get_rich_toolkit() as toolkit, APIClient() as client: toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI") diff --git a/src/fastapi_cloud_cli/commands/logs.py b/src/fastapi_cloud_cli/commands/logs.py index 16ea4ae9..e30774eb 100644 --- a/src/fastapi_cloud_cli/commands/logs.py +++ b/src/fastapi_cloud_cli/commands/logs.py @@ -9,6 +9,7 @@ from rich.markup import escape from rich_toolkit import RichToolkit +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( APIClient, AppLogEntry, @@ -16,7 +17,6 @@ TooManyRetriesError, ) from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_error logger = logging.getLogger(__name__) @@ -144,7 +144,7 @@ def logs( fastapi cloud logs --no-follow # Fetch recent logs and exit fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): toolkit.print( diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 562adc25..b7401c51 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -7,9 +7,9 @@ import typer +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import get_app_config -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -198,7 +198,7 @@ def setup_ci( fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index ad94f6a9..a33f5aec 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -4,30 +4,32 @@ from rich import print from rich_toolkit.progress import Progress +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import APIClient -from fastapi_cloud_cli.utils.auth import Identity from fastapi_cloud_cli.utils.cli import handle_http_errors logger = logging.getLogger(__name__) def whoami() -> Any: - identity = Identity() - - if identity.auth_mode == "token": - print("⚡ [bold]Using API token from environment variable[/bold]") - return + identity = ctx.get_identity() if not identity.is_logged_in(): print("No credentials found. Use [blue]`fastapi login`[/] to login.") - return - - with APIClient() as client: - with Progress(title="⚡ Fetching profile", transient=True) as progress: - with handle_http_errors(progress, default_message=""): - response = client.get("/users/me") - response.raise_for_status() - - data = response.json() - - print(f"⚡ [bold]{data['email']}[/bold]") + else: + with APIClient() as client: + with Progress(title="⚡ Fetching profile", transient=True) as progress: + with handle_http_errors(progress, default_message=""): + response = client.get("/users/me") + response.raise_for_status() + + data = response.json() + + print(f"⚡ [bold]{data['email']}[/bold]") + + # Deployment token status + if identity.deploy_token is not None: + print( + "⚡ [bold]Using API token from environment variable for " + "[blue]`fastapi deploy`[/blue] command.[/bold]" + ) diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py new file mode 100644 index 00000000..85acb8fc --- /dev/null +++ b/src/fastapi_cloud_cli/context.py @@ -0,0 +1,24 @@ +import logging +from typing import Literal + +from fastapi_cloud_cli.utils.auth import Identity + +logger = logging.getLogger(__name__) + + +class Context: + def __init__(self) -> None: + self._is_initialized = False + + def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: + logger.debug("Initializing context with prefer_auth_mode: %s", prefer_auth_mode) + self.prefer_auth_mode = prefer_auth_mode + self._is_initialized = True + + def get_identity(self) -> Identity: + if not self._is_initialized: # pragma: no cover + raise RuntimeError("Context must be initialized before use") + return Identity(prefer_auth_mode=self.prefer_auth_mode) + + +ctx = Context() diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 5815a753..0b08cda7 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -18,8 +18,7 @@ from fastapi_cloud_cli import __version__ from fastapi_cloud_cli.config import Settings - -from .auth import Identity +from fastapi_cloud_cli.context import ctx logger = logging.getLogger(__name__) @@ -193,7 +192,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: class APIClient(httpx.Client): def __init__(self) -> None: settings = Settings.get() - identity = Identity() + identity = ctx.get_identity() super().__init__( base_url=settings.base_api_url, diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index b542fa28..d0c1923f 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -109,34 +109,57 @@ def _is_jwt_expired(token: str) -> bool: class Identity: - auth_mode: Literal["token", "user"] - - def __init__(self) -> None: - self.token = _get_auth_token() - self.auth_mode = "user" + def __init__(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: + self._user_token = _get_auth_token() + self._auth_mode: Literal["token", "user"] = "user" + self._deploy_token: str | None = None # users using `FASTAPI_CLOUD_TOKEN` if env_token := self._get_token_from_env(): - self.token = env_token - self.auth_mode = "token" + logger.debug("Reading token from FASTAPI_CLOUD_TOKEN environment variable") + self._deploy_token = env_token + if prefer_auth_mode == "token": + self._auth_mode = "token" + logger.debug("Using `token` auth mode") def _get_token_from_env(self) -> str | None: return os.environ.get("FASTAPI_CLOUD_TOKEN") def is_expired(self) -> bool: - if not self.token: + if self._auth_mode != "user": # pragma: no cover # Should never happen + raise RuntimeError("Expiration check is only applicable for user tokens") + + if not self.user_token: return True - return _is_jwt_expired(self.token) + return _is_jwt_expired(self.user_token) def is_logged_in(self) -> bool: if self.token is None: logger.debug("Login status: False (no token)") return False - if self.auth_mode == "user" and self.is_expired(): + if self._auth_mode == "user" and self.is_expired(): logger.debug("Login status: False (token expired)") return False logger.debug("Login status: True") return True + + @property + def auth_mode(self) -> Literal["token", "user"]: + return self._auth_mode + + @property + def token(self) -> str | None: + if self._auth_mode == "token": + return self.deploy_token or self.user_token + return self.user_token + + @property + def user_token(self) -> str | None: + return self._user_token + + @property + def deploy_token(self) -> str | None: + return self._deploy_token diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index 72e25955..e91914dc 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -10,7 +10,9 @@ from rich_toolkit.progress import Progress from rich_toolkit.styles import MinimalStyle, TaggedStyle -from .auth import Identity, delete_auth_config +from fastapi_cloud_cli.context import ctx + +from .auth import delete_auth_config logger = logging.getLogger(__name__) @@ -78,7 +80,7 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: def handle_unauthorized() -> str: message = "The specified token is not valid. " - identity = Identity() + identity = ctx.get_identity() if identity.auth_mode == "user": delete_auth_config() diff --git a/src/fastapi_cloud_cli/utils/sentry.py b/src/fastapi_cloud_cli/utils/sentry.py index b4828e49..515589c2 100644 --- a/src/fastapi_cloud_cli/utils/sentry.py +++ b/src/fastapi_cloud_cli/utils/sentry.py @@ -7,8 +7,12 @@ def init_sentry() -> None: - """Initialize Sentry error tracking only if user is logged in.""" - identity = Identity() + """ + Initialize Sentry error tracking only if user is logged in or has a deployment token. + """ + identity = Identity( + prefer_auth_mode="token" # Use auth_mode="token" as it has a fallback to user token + ) if not identity.is_logged_in(): return diff --git a/tests/conftest.py b/tests/conftest.py index 852c90a3..f6b13d7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,3 +90,10 @@ def configured_app(tmp_path: Path) -> ConfiguredApp: config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') return ConfiguredApp(app_id=app_id, team_id=team_id, path=tmp_path) + + +@pytest.fixture(autouse=True) +def unset_env_vars(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: + """Fixture to unset environment variables that might interfere with tests.""" + monkeypatch.delenv("FASTAPI_CLOUD_TOKEN", raising=False) + yield diff --git a/tests/test_api_client.py b/tests/test_api_client.py index aac9cf25..a2b980ef 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -7,6 +7,7 @@ from httpx import Response from time_machine import TimeMachineFixture +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( STREAM_LOGS_MAX_RETRIES, APIClient, @@ -34,6 +35,11 @@ def logs_route(respx_mock: respx.MockRouter, deployment_id: str) -> respx.Route: return respx_mock.get(f"/deployments/{deployment_id}/build-logs") +@pytest.fixture(autouse=True) +def init_context() -> None: + ctx.initialize() # Initialize context with defaults + + def test_stream_build_logs_successful( logs_route: respx.Route, client: APIClient, diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index dae8ca99..6b99fcfb 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1554,9 +1554,19 @@ def test_cancel_upload_swallows_exceptions( assert "HTTPStatusError" not in result.output +@pytest.mark.parametrize( + "command", + [ + ["deploy"], + ["cloud", "deploy"], + ], +) @pytest.mark.respx def test_deploy_successfully_with_token( - logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter + logged_out_cli: None, + command: list[str], + tmp_path: Path, + respx_mock: respx.MockRouter, ) -> None: app_data = _get_random_app() team_data = _get_random_team() @@ -1616,12 +1626,15 @@ def test_deploy_successfully_with_token( ).mock(return_value=Response(200, json={**deployment_data, "status": "success"})) with changing_dir(tmp_path): - result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) + result = runner.invoke(app, command, env={"FASTAPI_CLOUD_TOKEN": "hello"}) assert result.exit_code == 0 # check that logs are shown assert "All good!" in result.output + assert ( + "Using token from FASTAPI_CLOUD_TOKEN environment variable" in result.output + ) # check that the app URL is shown assert deployment_data["url"] in result.output diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 274c78f1..2c03aed1 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -77,6 +77,57 @@ def test_full_login( assert '"access_token":"test_token_1234"' in temp_auth_config.read_text() +@pytest.mark.respx +def test_full_login_with_deploy_token_set( + respx_mock: respx.MockRouter, temp_auth_config: Path, settings: Settings +) -> None: + with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open: + respx_mock.post( + "/login/device/authorization", data={"client_id": settings.client_id} + ).mock( + return_value=Response( + 200, + json={ + "verification_uri_complete": "http://test.com", + "verification_uri": "http://test.com", + "user_code": "1234", + "device_code": "5678", + }, + ) + ) + respx_mock.post( + "/login/device/token", + data={ + "device_code": "5678", + "client_id": settings.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ).mock(return_value=Response(200, json={"access_token": "test_token_1234"})) + + # Verify no auth file exists before login + assert not temp_auth_config.exists() + + result = runner.invoke( + app, + ["login"], + env={"FASTAPI_CLOUD_TOKEN": "test_deploy_token"}, # Should be ignored + ) + + assert result.exit_code == 0 + assert mock_open.called + assert mock_open.call_args.args == ("http://test.com",) + + # Verify the warning message is shown + assert "You have FASTAPI_CLOUD_TOKEN environment variable set." in result.output + assert "This token will take precedence over the user token" in result.output + + assert "Now you are logged in!" in result.output + + # Verify auth file was created with correct content + assert temp_auth_config.exists() + assert '"access_token":"test_token_1234"' in temp_auth_config.read_text() + + @pytest.mark.respx def test_fetch_access_token_success_immediately( respx_mock: respx.MockRouter, settings: Settings diff --git a/tests/test_sentry.py b/tests/test_sentry.py index 07f4aae1..fffa272d 100644 --- a/tests/test_sentry.py +++ b/tests/test_sentry.py @@ -1,6 +1,8 @@ from pathlib import Path from unittest.mock import ANY, patch +import pytest + from fastapi_cloud_cli.utils.sentry import SENTRY_DSN, init_sentry @@ -20,3 +22,17 @@ def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None: init_sentry() mock_init.assert_not_called() + + +def test_init_sentry_when_deployment_token( + logged_out_cli: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FASTAPI_CLOUD_TOKEN", "deployment-token") + with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init: + init_sentry() + + mock_init.assert_called_once_with( + dsn=SENTRY_DSN, + integrations=[ANY], # TyperIntegration instance + send_default_pii=False, + )