Skip to content

Commit 3a6c62c

Browse files
thomasjpfanmodal-bot
authored andcommitted
Add client CLI for promote (#51577)
GitOrigin-RevId: e8d379d95bf2b726b1bdd017371a609eafcee3b0
1 parent 5a8b875 commit 3a6c62c

3 files changed

Lines changed: 225 additions & 9 deletions

File tree

py/modal/cli/app.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,57 @@ async def logs(
351351
)
352352

353353

354+
@app_cli.command("promote", no_args_is_help=True, hidden=True)
355+
@click.argument("app_identifier")
356+
@click.argument("version")
357+
@env_option
358+
@synchronizer.create_blocking
359+
async def promote(
360+
app_identifier: str,
361+
version: str,
362+
*,
363+
env: str | None = None,
364+
):
365+
"""Deploy a staged version from an App's deployment history.
366+
367+
When a staged version gets promoted, the app is deployed with a new version that
368+
refers back to the staged version.
369+
370+
Examples:
371+
372+
Promote an App to a specific version:
373+
374+
```
375+
modal app promote my-app v5
376+
```
377+
378+
Promote an App using its App ID instead of its name:
379+
380+
```
381+
modal app promote ap-abcdefghABCDEFGH123456 v5
382+
```
383+
384+
"""
385+
if m := re.fullmatch(r"v?([1-9]\d*)", version):
386+
version_number = int(m.group(1))
387+
else:
388+
raise UsageError(f"Invalid version specifier: {version}. Expected a positive version number, e.g. 'v5' or '5'.")
389+
390+
env = ensure_env(env)
391+
client = await _Client.from_env()
392+
app_id, environment_name, lifecycle = await resolve_app_identifier(app_identifier, env, client)
393+
if lifecycle.app_state != api_pb2.APP_STATE_DEPLOYED:
394+
env_suffix = f" in the '{environment_name}' environment" if environment_name else ""
395+
raise InvalidError(f"App '{app_identifier}' is not deployed{env_suffix}.")
396+
397+
resp = await client.stub.AppPromote(api_pb2.AppPromoteRequest(app_id=app_id, version=version_number))
398+
print_server_warnings(resp.server_warnings)
399+
400+
output_mgr = OutputManager.get()
401+
output_mgr.print(f"[green]✓[/green] Promoted App to v{version_number}!")
402+
output_mgr.print(f"\nView Deployment: [magenta]{resp.url}[/magenta]")
403+
404+
354405
@app_cli.command("rollback", no_args_is_help=True, context_settings={"ignore_unknown_options": True})
355406
@click.argument("app_identifier")
356407
@click.argument("version", default="")

py/test/cli_test.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2496,6 +2496,107 @@ def test_app_rollback(servicer, mock_dir, set_env_client):
24962496
run_cli_command(["app", "rollback", "my_app", "2"], expected_exit_code=2)
24972497

24982498

2499+
def _record_future_deployment(servicer, app_id: str, version: int) -> None:
2500+
"""Add a deployment to an App's history that is newer than the currently deployed version."""
2501+
latest = servicer.app_deployment_history[app_id][-1]
2502+
servicer.app_deployment_history[app_id].append({**latest, "version": version, "tag": f"deploy{version}"})
2503+
2504+
2505+
def test_app_rollback_relative_to_live_version(servicer, mock_dir, set_env_client):
2506+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2507+
for _ in range(3):
2508+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2509+
app_id = servicer.deployed_apps[("main", "my_app")]
2510+
_record_future_deployment(servicer, app_id, version=5)
2511+
2512+
# Relative versions resolve against the live version (v3), not the newest staged row (v5),
2513+
# but the new deployment still lands above everything in history.
2514+
run_cli_command(["app", "rollback", "my_app"])
2515+
assert servicer.app_deployment_history[app_id][-1]["rollback_version"] == 2
2516+
assert servicer.app_deployment_history[app_id][-1]["version"] == 6
2517+
2518+
2519+
@pytest.mark.parametrize("version", ["v5", "5"])
2520+
def test_app_promote(servicer, mock_dir, set_env_client, version):
2521+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2522+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2523+
app_id = servicer.deployed_apps[("main", "my_app")]
2524+
_record_future_deployment(servicer, app_id, version=5)
2525+
2526+
with servicer.intercept() as ctx:
2527+
res = run_cli_command(["app", "promote", "my_app", version])
2528+
2529+
(request,) = ctx.get_requests("AppPromote")
2530+
assert request.app_id == app_id
2531+
assert request.version == 5
2532+
2533+
assert "Promoted App to v5" in res.stdout
2534+
assert "http://test.modal.com/foo/bar" in res.stdout
2535+
2536+
# The promotion is recorded as a new deployment above every version in history
2537+
assert servicer.app_deployment_history[app_id][-1]["version"] == 6
2538+
assert servicer.app_deployment_history[app_id][-1]["rollback_version"] == 5
2539+
2540+
2541+
@pytest.mark.parametrize("version", ["v0", "0", "v", "latest", "-1", "v5.1", "5x"])
2542+
def test_app_promote_invalid_version(servicer, mock_dir, set_env_client, version):
2543+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2544+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2545+
2546+
with servicer.intercept() as ctx:
2547+
run_cli_command(["app", "promote", "my_app", version], expected_exit_code=2)
2548+
2549+
# Version specifiers are validated before we hit the server
2550+
assert not ctx.get_requests("AppPromote")
2551+
2552+
2553+
def test_app_promote_requires_version(servicer, mock_dir, set_env_client):
2554+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2555+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2556+
2557+
run_cli_command(["app", "promote", "my_app"], expected_exit_code=2)
2558+
2559+
2560+
def test_app_promote_not_deployed(servicer, mock_dir, set_env_client):
2561+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2562+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2563+
app_id = servicer.deployed_apps[("main", "my_app")]
2564+
_record_future_deployment(servicer, app_id, version=5)
2565+
servicer.app_state_history[app_id].append(api_pb2.APP_STATE_STOPPED)
2566+
2567+
with servicer.intercept() as ctx:
2568+
run_cli_command(
2569+
["app", "promote", "my_app", "v5"], expected_exit_code=1, expected_error="App .* is not deployed"
2570+
)
2571+
2572+
assert not ctx.get_requests("AppPromote")
2573+
2574+
2575+
def test_app_promote_version_not_newer(servicer, mock_dir, set_env_client):
2576+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2577+
for _ in range(2):
2578+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2579+
2580+
# v1 and v2 exist in the App history, but the App already serves v2
2581+
for version in ["v1", "v2"]:
2582+
run_cli_command(
2583+
["app", "promote", "my_app", version],
2584+
expected_exit_code=1,
2585+
expected_error="must be newer than the current App version",
2586+
)
2587+
2588+
2589+
def test_app_promote_version_not_in_history(servicer, mock_dir, set_env_client):
2590+
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
2591+
run_cli_command(["deploy", "myapp.py", "--name", "my_app"])
2592+
2593+
run_cli_command(
2594+
["app", "promote", "my_app", "v100"],
2595+
expected_exit_code=1,
2596+
expected_error="not found in App history",
2597+
)
2598+
2599+
24992600
def test_dict_create_list_delete(servicer, server_url_env, set_env_client):
25002601
run_cli_command(["dict", "create", "foo-dict"])
25012602
run_cli_command(["dict", "create", "bar-dict"])
@@ -3246,6 +3347,8 @@ async def task_list(servicer, stream):
32463347
ctx.set_responder("TaskList", task_list)
32473348

32483349
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
3350+
# Deploy twice so that there is an earlier version for `rollback` to target
3351+
run_cli_command(["deploy", "myapp.py"])
32493352
run_cli_command(["deploy", "myapp.py"])
32503353

32513354
res = run_cli_command(["app", cmd, "my_app", "--strategy", "recreate"])
@@ -3259,6 +3362,8 @@ async def task_list(servicer, stream):
32593362
@pytest.mark.parametrize("cmd", ["rollover", "rollback"])
32603363
def test_rolling_strategy(cmd, servicer, mock_dir, set_env_client):
32613364
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
3365+
# Deploy twice so that there is an earlier version for `rollback` to target
3366+
run_cli_command(["deploy", "myapp.py"])
32623367
run_cli_command(["deploy", "myapp.py"])
32633368

32643369
res = run_cli_command(["app", cmd, "my_app"])

py/test/conftest.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,9 @@ def __init__(self, blob_host, blobs, blocks, files_sha2data, credentials):
747747
self.deployed_apps: dict[tuple[str, str], str] = {}
748748
self.app_environments: dict[str, str] = {}
749749
self.app_deployment_history: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
750+
# The currently live version of each App, which can lag behind the newest version in the
751+
# deployment history (e.g. after a rollback, or when a newer version has not been promoted).
752+
self.app_live_version: dict[str, int] = {}
750753
self.app_deployment_history["ap-x"] = [
751754
{
752755
"app_id": "ap-x",
@@ -1234,23 +1237,81 @@ async def AppGetLogs(self, stream):
12341237
await stream.send_message(api_pb2.TaskLogsBatch(entry_id=last_entry_id, items=[log]))
12351238
await stream.send_message(api_pb2.TaskLogsBatch(app_done=True))
12361239

1240+
def _max_history_version(self, app_id: str) -> int:
1241+
return max((h["version"] for h in self.app_deployment_history[app_id]), default=0)
1242+
1243+
async def AppPromote(self, stream):
1244+
request: api_pb2.AppPromoteRequest = await stream.recv_message()
1245+
history = self.app_deployment_history[request.app_id]
1246+
current_version = self.app_live_version.get(request.app_id, self._max_history_version(request.app_id))
1247+
1248+
if request.version <= 0:
1249+
raise GRPCError(Status.INVALID_ARGUMENT, "Invalid promote version request.")
1250+
if request.version <= current_version:
1251+
raise GRPCError(
1252+
Status.INVALID_ARGUMENT,
1253+
f"Promote version must be newer than the current App version ({current_version}).",
1254+
)
1255+
1256+
promote_history = next((h for h in history if h["version"] == request.version), None)
1257+
if promote_history is None:
1258+
raise GRPCError(Status.NOT_FOUND, "Promote version not found in App history.")
1259+
1260+
# Mirrors the server: the new version clears every row in history, not just the live one,
1261+
# so promoting the immediately-next version does not reuse its version number.
1262+
new_version = max(current_version, self._max_history_version(request.app_id)) + 1
1263+
deployed_at = datetime.datetime.now().timestamp()
1264+
self.app_deployment_history[request.app_id].append(
1265+
{
1266+
"app_id": request.app_id,
1267+
"deployed_at": deployed_at,
1268+
"version": new_version,
1269+
"client_version": promote_history["client_version"],
1270+
"deployed_by": "foo-user",
1271+
"tag": promote_history["tag"],
1272+
"rollback_version": request.version,
1273+
"definition_ids": promote_history["definition_ids"],
1274+
"function_ids": promote_history["function_ids"],
1275+
"commit_info": promote_history.get("commit_info", None),
1276+
}
1277+
)
1278+
self.app_live_version[request.app_id] = new_version
1279+
self.app_objects[request.app_id] = dict(promote_history["function_ids"])
1280+
self.app_state_history[request.app_id].append(api_pb2.APP_STATE_DEPLOYED)
1281+
response = api_pb2.AppPromoteResponse(
1282+
url="http://test.modal.com/foo/bar",
1283+
server_warnings=[],
1284+
deployed_at=deployed_at,
1285+
)
1286+
await stream.send_message(response)
1287+
12371288
async def AppRollback(self, stream):
12381289
request: api_pb2.AppRollbackRequest = await stream.recv_message()
1239-
history = self.app_deployment_history[request.app_id][-1]
1240-
current_version = history["version"]
1290+
history = self.app_deployment_history[request.app_id]
1291+
# Mirrors the server: relative versions resolve against the App's live version, which can trail
1292+
# staged rows in history.
1293+
current_version = self.app_live_version.get(request.app_id, self._max_history_version(request.app_id))
12411294
if request.version < 0:
12421295
rollback_version = current_version + request.version
1243-
else:
1296+
if rollback_version <= 0:
1297+
raise GRPCError(Status.INVALID_ARGUMENT, "Rollback request exceeds number of App deployments.")
1298+
elif request.version > 0:
12441299
rollback_version = request.version
1300+
else:
1301+
raise GRPCError(Status.INVALID_ARGUMENT, "Invalid rollback version request.")
12451302

1246-
rollback_history = self.app_deployment_history[request.app_id][rollback_version - 1]
1303+
rollback_history = next((h for h in history if h["version"] == rollback_version), None)
1304+
if rollback_history is None:
1305+
raise GRPCError(Status.NOT_FOUND, "Rollback version not found in App history.")
12471306
rollback_client = rollback_history["client_version"]
1307+
# Mirrors the server: the new version sits above every row in history, not just the live one.
1308+
new_version = max(current_version, self._max_history_version(request.app_id)) + 1
12481309
deployed_at = datetime.datetime.now().timestamp()
12491310
self.app_deployment_history[request.app_id].append(
12501311
{
12511312
"app_id": request.app_id,
12521313
"deployed_at": deployed_at,
1253-
"version": current_version + 1,
1314+
"version": new_version,
12541315
"client_version": rollback_client,
12551316
"deployed_by": "foo-user",
12561317
"tag": "latest",
@@ -1260,6 +1321,7 @@ async def AppRollback(self, stream):
12601321
"commit_info": rollback_history.get("commit_info", None),
12611322
}
12621323
)
1324+
self.app_live_version[request.app_id] = new_version
12631325

12641326
self.app_state_history[request.app_id].append(api_pb2.APP_STATE_DEPLOYED)
12651327
response = api_pb2.AppRollbackResponse(
@@ -1333,10 +1395,7 @@ async def _app_publish(self, request: api_pb2.AppPublishRequest):
13331395

13341396
self.app_objects[request.app_id] = {**request.function_ids, **request.class_ids}
13351397
self.app_state_history[request.app_id].append(request.app_state)
1336-
if current_history := self.app_deployment_history[request.app_id]:
1337-
current_version = current_history[-1]["version"]
1338-
else:
1339-
current_version = 0
1398+
current_version = self._max_history_version(request.app_id)
13401399

13411400
self.app_deployment_history[request.app_id].append(
13421401
{
@@ -1352,6 +1411,7 @@ async def _app_publish(self, request: api_pb2.AppPublishRequest):
13521411
"function_ids": dict(request.function_ids),
13531412
}
13541413
)
1414+
self.app_live_version[request.app_id] = current_version + 1
13551415
return response
13561416

13571417
async def AppGetByDeploymentName(self, stream):

0 commit comments

Comments
 (0)