Skip to content

Commit ae692af

Browse files
buurropatrick91
andauthored
✨ Add support for app ID in fastapi deploy (#144)
Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com>
1 parent d94be0a commit ae692af

File tree

2 files changed

+231
-10
lines changed

2 files changed

+231
-10
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -557,12 +557,22 @@ def deploy(
557557
skip_wait: Annotated[
558558
bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
559559
] = False,
560+
provided_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(
574+
"Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id
575+
)
566576

567577
identity = Identity()
568578

@@ -604,19 +614,43 @@ def deploy(
604614

605615
app_config = get_app_config(path_to_deploy)
606616

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

616650
with toolkit.progress("Checking app...", transient=True) as progress:
617651
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)
652+
logger.debug("Checking app with ID: %s", target_app_id)
653+
app = _get_app(target_app_id)
620654

621655
if not app:
622656
logger.debug("App not found in API")
@@ -626,10 +660,12 @@ def deploy(
626660

627661
if not app:
628662
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-
)
663+
664+
if not provided_app_id:
665+
toolkit.print(
666+
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
667+
tag="tip",
668+
)
633669
raise typer.Exit(1)
634670

635671
with tempfile.TemporaryDirectory() as temp_dir:

tests/test_cli_deploy.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,3 +1216,188 @@ def test_deploy_with_token_fails(
12161216
"The specified token is not valid. Make sure to use a valid token."
12171217
in result.output
12181218
)
1219+
1220+
1221+
@pytest.mark.respx(base_url=settings.base_api_url)
1222+
def test_deploy_with_app_id_arg(
1223+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1224+
) -> None:
1225+
app_data = _get_random_app()
1226+
app_id = app_data["id"]
1227+
deployment_data = _get_random_deployment(app_id=app_id)
1228+
1229+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1230+
1231+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1232+
return_value=Response(201, json=deployment_data)
1233+
)
1234+
1235+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1236+
return_value=Response(
1237+
200,
1238+
json={"url": "http://test.com", "fields": {"key": "value"}},
1239+
)
1240+
)
1241+
1242+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1243+
return_value=Response(200)
1244+
)
1245+
1246+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1247+
return_value=Response(200)
1248+
)
1249+
1250+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1251+
return_value=Response(
1252+
200,
1253+
content=build_logs_response(
1254+
{"type": "message", "message": "Building...", "id": "1"},
1255+
{"type": "complete"},
1256+
),
1257+
)
1258+
)
1259+
1260+
with changing_dir(tmp_path):
1261+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1262+
1263+
assert result.exit_code == 0
1264+
assert f"Deploying to app {app_id}" in result.output
1265+
1266+
1267+
@pytest.mark.respx(base_url=settings.base_api_url)
1268+
def test_deploy_with_app_id_from_env_var(
1269+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1270+
) -> None:
1271+
app_data = _get_random_app()
1272+
app_id = app_data["id"]
1273+
deployment_data = _get_random_deployment(app_id=app_id)
1274+
1275+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1276+
1277+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1278+
return_value=Response(201, json=deployment_data)
1279+
)
1280+
1281+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1282+
return_value=Response(
1283+
200,
1284+
json={"url": "http://test.com", "fields": {"key": "value"}},
1285+
)
1286+
)
1287+
1288+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1289+
return_value=Response(200)
1290+
)
1291+
1292+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1293+
return_value=Response(200)
1294+
)
1295+
1296+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1297+
return_value=Response(
1298+
200,
1299+
content=build_logs_response(
1300+
{"type": "message", "message": "Building...", "id": "1"},
1301+
{"type": "complete"},
1302+
),
1303+
)
1304+
)
1305+
1306+
with changing_dir(tmp_path):
1307+
result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id})
1308+
1309+
assert result.exit_code == 0
1310+
assert f"Deploying to app {app_id}" in result.output
1311+
1312+
1313+
@pytest.mark.respx(base_url=settings.base_api_url)
1314+
def test_deploy_with_app_id_matching_local_config(
1315+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1316+
) -> None:
1317+
app_data = _get_random_app()
1318+
app_id = app_data["id"]
1319+
team_id = "some-team-id"
1320+
deployment_data = _get_random_deployment(app_id=app_id)
1321+
1322+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
1323+
config_path.parent.mkdir(parents=True, exist_ok=True)
1324+
config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1325+
1326+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1327+
1328+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1329+
return_value=Response(201, json=deployment_data)
1330+
)
1331+
1332+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1333+
return_value=Response(
1334+
200,
1335+
json={"url": "http://test.com", "fields": {"key": "value"}},
1336+
)
1337+
)
1338+
1339+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1340+
return_value=Response(200)
1341+
)
1342+
1343+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1344+
return_value=Response(200)
1345+
)
1346+
1347+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1348+
return_value=Response(
1349+
200,
1350+
content=build_logs_response(
1351+
{"type": "message", "message": "Building...", "id": "1"},
1352+
{"type": "complete"},
1353+
),
1354+
)
1355+
)
1356+
1357+
with changing_dir(tmp_path):
1358+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1359+
1360+
assert result.exit_code == 0
1361+
# Should NOT show mismatch warning
1362+
assert "does not match" not in result.output
1363+
assert f"Deploying to app {app_id}" in result.output
1364+
1365+
1366+
@pytest.mark.respx(base_url=settings.base_api_url)
1367+
def test_deploy_with_app_id_mismatch_fails(
1368+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1369+
) -> None:
1370+
local_app_data = _get_random_app()
1371+
local_app_id = local_app_data["id"]
1372+
team_id = "some-team-id"
1373+
1374+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
1375+
config_path.parent.mkdir(parents=True, exist_ok=True)
1376+
config_path.write_text(f'{{"app_id": "{local_app_id}", "team_id": "{team_id}"}}')
1377+
1378+
cli_app_id = "different-app-id"
1379+
1380+
with changing_dir(tmp_path):
1381+
result = runner.invoke(app, ["deploy", "--app-id", cli_app_id])
1382+
1383+
assert result.exit_code == 1
1384+
assert "does not match" in result.output
1385+
assert "fastapi cloud unlink" in result.output
1386+
assert "FASTAPI_CLOUD_APP_ID" in result.output
1387+
1388+
1389+
@pytest.mark.respx(base_url=settings.base_api_url)
1390+
def test_deploy_with_app_id_arg_app_not_found(
1391+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1392+
) -> None:
1393+
app_id = "nonexistent-app-id"
1394+
1395+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404))
1396+
1397+
with changing_dir(tmp_path):
1398+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1399+
1400+
assert result.exit_code == 1
1401+
assert "App not found" in result.output
1402+
# Should NOT show unlink tip when using --app-id
1403+
assert "unlink" not in result.output

0 commit comments

Comments
 (0)