Skip to content

Commit 046ddfc

Browse files
buurropatrick91
authored andcommitted
✨ Add support for app ID in fastapi deploy
1 parent 7495cd8 commit 046ddfc

File tree

3 files changed

+478
-10
lines changed

3 files changed

+478
-10
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -557,12 +557,20 @@ def deploy(
557557
skip_wait: Annotated[
558558
bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
559559
] = False,
560+
app_id: Annotated[
561+
Union[str, None],
562+
typer.Option(
563+
"--app-id",
564+
help="Application ID to deploy to",
565+
envvar="FASTAPI_CLOUD_APP_ID",
566+
),
567+
] = None,
560568
) -> Any:
561569
"""
562570
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀
563571
"""
564572
logger.debug("Deploy command started")
565-
logger.debug("Deploy path: %s, skip_wait: %s", path, skip_wait)
573+
logger.debug("Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, app_id)
566574

567575
identity = Identity()
568576

@@ -604,19 +612,44 @@ def deploy(
604612

605613
app_config = get_app_config(path_to_deploy)
606614

607-
if not app_config:
615+
if app_id and app_config and app_id != app_config.app_id:
616+
logger.debug(
617+
"Provided app_id (%s) differs from local config (%s)",
618+
app_id,
619+
app_config.app_id,
620+
)
621+
622+
toolkit.print(
623+
f"[error]Error: Provided app ID ({app_id}) does not match the local "
624+
f"config ({app_config.app_id}).[/]"
625+
)
626+
toolkit.print_line()
627+
toolkit.print(
628+
"Run [bold]fastapi cloud unlink[/] to remove the local config, "
629+
"or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app.",
630+
tag="tip",
631+
)
632+
raise typer.Exit(1) from None
633+
634+
target_app_id = app_id or (app_config.app_id if app_config else None)
635+
636+
if not target_app_id:
608637
logger.debug("No app config found, configuring new app")
609638
app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy)
610639
toolkit.print_line()
640+
target_app_id = app_config.app_id
641+
642+
if app_id:
643+
toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...")
611644
else:
612-
logger.debug("Existing app config found, proceeding with deployment")
613645
toolkit.print("Deploying app...")
614-
toolkit.print_line()
646+
647+
toolkit.print_line()
615648

616649
with toolkit.progress("Checking app...", transient=True) as progress:
617650
with handle_http_errors(progress):
618-
logger.debug("Checking app with ID: %s", app_config.app_id)
619-
app = _get_app(app_config.app_id)
651+
logger.debug("Checking app with ID: %s", target_app_id)
652+
app = _get_app(target_app_id)
620653

621654
if not app:
622655
logger.debug("App not found in API")
@@ -626,10 +659,11 @@ def deploy(
626659

627660
if not app:
628661
toolkit.print_line()
629-
toolkit.print(
630-
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
631-
tag="tip",
632-
)
662+
if not app_id:
663+
toolkit.print(
664+
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
665+
tag="tip",
666+
)
633667
raise typer.Exit(1)
634668

635669
with tempfile.TemporaryDirectory() as temp_dir:
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import json
2+
import logging
3+
import time
4+
from collections.abc import Generator
5+
from datetime import datetime
6+
from pathlib import Path
7+
from typing import Annotated, Optional
8+
9+
import typer
10+
from httpx import HTTPError, HTTPStatusError, ReadTimeout
11+
from pydantic import BaseModel, ValidationError
12+
from rich.markup import escape
13+
from rich_toolkit import RichToolkit
14+
15+
from fastapi_cloud_cli.utils.api import APIClient
16+
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
17+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
18+
19+
logger = logging.getLogger(__name__)
20+
21+
MAX_RECONNECT_ATTEMPTS = 10
22+
RECONNECT_DELAY_SECONDS = 1
23+
LOG_LEVEL_COLORS = {
24+
"debug": "blue",
25+
"info": "cyan",
26+
"warning": "yellow",
27+
"warn": "yellow",
28+
"error": "red",
29+
"critical": "magenta",
30+
"fatal": "magenta",
31+
}
32+
33+
34+
class LogEntry(BaseModel):
35+
timestamp: datetime
36+
message: str
37+
level: str = "unknown"
38+
39+
40+
def _stream_logs(
41+
app_id: str,
42+
tail: int,
43+
since: str,
44+
follow: bool,
45+
) -> Generator[str, None, None]:
46+
with APIClient() as client:
47+
timeout = 120 if follow else 30
48+
with client.stream(
49+
"GET",
50+
f"/apps/{app_id}/logs/stream",
51+
params={
52+
"tail": tail,
53+
"since": since,
54+
"follow": follow,
55+
},
56+
timeout=timeout,
57+
) as response:
58+
response.raise_for_status()
59+
60+
yield from response.iter_lines()
61+
62+
63+
def _format_log_line(log: LogEntry) -> str:
64+
"""Format a log entry for display with a colored indicator"""
65+
timestamp_str = log.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
66+
color = LOG_LEVEL_COLORS.get(log.level.lower())
67+
68+
message = escape(log.message)
69+
70+
if color:
71+
return f"[{color}]┃[/{color}] [dim]{timestamp_str}[/dim] {message}"
72+
73+
return f"[dim]┃[/dim] [dim]{timestamp_str}[/dim] {message}"
74+
75+
76+
def _process_log_stream(
77+
toolkit: RichToolkit,
78+
app_config: AppConfig,
79+
tail: int,
80+
since: str,
81+
follow: bool,
82+
) -> None:
83+
log_count = 0
84+
last_timestamp: datetime | None = None
85+
current_since = since
86+
current_tail = tail
87+
reconnect_attempts = 0
88+
89+
while True:
90+
try:
91+
for line in _stream_logs(
92+
app_id=app_config.app_id,
93+
tail=current_tail,
94+
since=current_since,
95+
follow=follow,
96+
):
97+
if not line: # pragma: no cover
98+
continue
99+
100+
try:
101+
data = json.loads(line)
102+
except json.JSONDecodeError:
103+
logger.debug("Failed to parse log line: %s", line)
104+
continue
105+
106+
# Skip heartbeat messages
107+
if data.get("type") == "heartbeat": # pragma: no cover
108+
continue
109+
110+
if data.get("type") == "error":
111+
toolkit.print(
112+
f"Error: {data.get('message', 'Unknown error')}",
113+
)
114+
raise typer.Exit(1)
115+
116+
# Parse and display log entry
117+
try:
118+
log_entry = LogEntry.model_validate(data)
119+
toolkit.print(_format_log_line(log_entry))
120+
log_count += 1
121+
last_timestamp = log_entry.timestamp
122+
# Reset reconnect attempts on successful log receipt
123+
reconnect_attempts = 0
124+
except ValidationError as e: # pragma: no cover
125+
logger.debug("Failed to parse log entry: %s - %s", data, e)
126+
continue
127+
128+
# Stream ended normally (only happens with --no-follow)
129+
if not follow and log_count == 0:
130+
toolkit.print("No logs found for the specified time range.")
131+
break
132+
133+
except KeyboardInterrupt: # pragma: no cover
134+
toolkit.print_line()
135+
break
136+
except (ReadTimeout, HTTPError) as e:
137+
# In follow mode, try to reconnect on connection issues
138+
if follow and not isinstance(e, HTTPStatusError):
139+
reconnect_attempts += 1
140+
if reconnect_attempts >= MAX_RECONNECT_ATTEMPTS:
141+
toolkit.print(
142+
"Lost connection to log stream. Please try again later.",
143+
)
144+
raise typer.Exit(1) from None
145+
146+
logger.debug(
147+
"Connection lost, reconnecting (attempt %d/%d)...",
148+
reconnect_attempts,
149+
MAX_RECONNECT_ATTEMPTS,
150+
)
151+
152+
# On reconnect, resume from last seen timestamp
153+
# The API uses strict > comparison, so logs with the same timestamp
154+
# as last_timestamp will be filtered out (no duplicates)
155+
if last_timestamp: # pragma: no cover
156+
current_since = last_timestamp.isoformat()
157+
current_tail = 0 # Don't fetch historical logs again
158+
159+
time.sleep(RECONNECT_DELAY_SECONDS)
160+
continue
161+
162+
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
163+
toolkit.print(
164+
"The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
165+
)
166+
if isinstance(e, HTTPStatusError) and e.response.status_code == 404:
167+
toolkit.print(
168+
"App not found. Make sure to use the correct account.",
169+
)
170+
elif isinstance(e, ReadTimeout):
171+
toolkit.print(
172+
"The request timed out. Please try again later.",
173+
)
174+
else:
175+
logger.exception("Failed to fetch logs")
176+
177+
toolkit.print(
178+
"Failed to fetch logs. Please try again later.",
179+
)
180+
raise typer.Exit(1) from None
181+
182+
183+
def logs(
184+
path: Annotated[
185+
Optional[Path],
186+
typer.Argument(
187+
help="Path to the folder containing the app (defaults to current directory)"
188+
),
189+
] = None,
190+
tail: int = typer.Option(
191+
100,
192+
"--tail",
193+
"-t",
194+
help="Number of log lines to show before streaming.",
195+
show_default=True,
196+
),
197+
since: str = typer.Option(
198+
"5m",
199+
"--since",
200+
"-s",
201+
help="Show logs since a specific time (e.g., '5m', '1h', '2d').",
202+
show_default=True,
203+
),
204+
follow: bool = typer.Option(
205+
True,
206+
"--follow/--no-follow",
207+
"-f",
208+
help="Stream logs in real-time (use --no-follow to fetch and exit).",
209+
),
210+
) -> None:
211+
"""Stream or fetch logs from your deployed app."""
212+
with get_rich_toolkit(minimal=True) as toolkit:
213+
if not is_logged_in():
214+
toolkit.print(
215+
"No credentials found. Use [blue]`fastapi login`[/] to login.",
216+
tag="auth",
217+
)
218+
raise typer.Exit(1)
219+
220+
app_path = path or Path.cwd()
221+
app_config = get_app_config(app_path)
222+
223+
if not app_config:
224+
toolkit.print(
225+
"No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
226+
)
227+
raise typer.Exit(1)
228+
229+
logger.debug("Fetching logs for app ID: %s", app_config.app_id)
230+
231+
if follow:
232+
toolkit.print(
233+
f"Streaming logs for [bold]{app_config.app_id}[/bold] (Ctrl+C to exit)...",
234+
tag="logs",
235+
)
236+
else:
237+
toolkit.print(
238+
f"Fetching logs for [bold]{app_config.app_id}[/bold]...",
239+
tag="logs",
240+
)
241+
toolkit.print_line()
242+
243+
_process_log_stream(
244+
toolkit=toolkit,
245+
app_config=app_config,
246+
tail=tail,
247+
since=since,
248+
follow=follow,
249+
)

0 commit comments

Comments
 (0)