Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions projects/fal/src/fal/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,7 @@ def register(
result_handler: ResultHandler | None = None,
entrypoint: str | None = None,
build_environment: bool | None = None,
attach_to_deployment: bool | None = None,
) -> Optional[RegisterApplicationResult]:
options = self.prepare_options(options, func=func)
environment_options = options.environment
Expand Down Expand Up @@ -1200,6 +1201,7 @@ def register(
data_mounts=data_mounts,
entrypoint=entrypoint,
build_environment=build_environment,
attach_to_deployment=attach_to_deployment,
):
result_handler(partial_result)

Expand Down
21 changes: 21 additions & 0 deletions projects/fal/src/fal/api/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def _resolve_deployment_reference(
auth: AuthModeLiteral | None = None,
strategy: DeploymentStrategyLiteral = "rolling",
reset_scale: bool = False,
attach_to_deployment: bool | None = None,
) -> tuple[tuple[str | None, str | None], AppData]:
from fal.cli._utils import AppData, get_app_data_from_toml, is_app_name
from fal.cli.parser import RefAction
Expand All @@ -129,6 +130,7 @@ def _resolve_deployment_reference(
auth=auth,
deployment_strategy=cast(DeploymentStrategyLiteral, strategy),
reset_scale=cast(bool, reset_scale),
attach_to_deployment=attach_to_deployment,
name=app_name,
)

Expand All @@ -141,6 +143,8 @@ def _resolve_deployment_reference(
assert resolved_app_name is not None

app_data = get_app_data_from_toml(resolved_app_name)
if attach_to_deployment is not None:
app_data = replace(app_data, attach_to_deployment=attach_to_deployment)
if app_data.python_entry_point is not None or app_data.ref is None:
# python_entry_point is resolved by the loader; ref is None for
# image-only apps.
Expand All @@ -156,6 +160,17 @@ def _resolve_deployment_reference(
return (file_path, func_name), replace(app_data, ref=ref)


def _validate_attach_to_deployment(app_data: AppData) -> None:
from fal.api import FalServerlessError

strategy = app_data.deployment_strategy or "rolling"
if app_data.attach_to_deployment is not None and strategy != "rolling":
raise FalServerlessError(
"--attach/--detach only applies to rolling deployments. "
"Use --strategy rolling or remove the attach flag."
)


def _prepare_deployment_from_reference(
client: SyncServerlessClient,
app_ref: tuple[str | Path | None, str | None],
Expand Down Expand Up @@ -246,6 +261,7 @@ def _execute_loaded_deployment(
) -> DeploymentResult:
from fal.api import FalServerlessError

_validate_attach_to_deployment(app_data)
strategy = app_data.deployment_strategy or "rolling"
build_result_handler = (
result_handler if build_result_handler is None else build_result_handler
Expand Down Expand Up @@ -280,6 +296,7 @@ def _execute_loaded_deployment(
metadata=isolated_function.build_metadata(),
deployment_strategy=strategy,
scale=app_data.reset_scale,
attach_to_deployment=app_data.attach_to_deployment,
environment_name=environment_name,
result_handler=result_handler,
entrypoint=isolated_function.run_entrypoint,
Expand Down Expand Up @@ -327,6 +344,7 @@ def prepare_deployment(
auth: AuthModeLiteral | None = None,
strategy: DeploymentStrategyLiteral = "rolling",
reset_scale: bool = False,
attach_to_deployment: bool | None = None,
force_env_build: bool = False,
environment_name: str | None = None,
) -> PreparedDeployment:
Expand All @@ -336,6 +354,7 @@ def prepare_deployment(
auth=auth,
strategy=strategy,
reset_scale=reset_scale,
attach_to_deployment=attach_to_deployment,
)
return _prepare_deployment_from_reference(
client,
Expand Down Expand Up @@ -373,6 +392,7 @@ def deploy(
auth: AuthModeLiteral | None = None,
strategy: DeploymentStrategyLiteral = "rolling",
reset_scale: bool = False,
attach_to_deployment: bool | None = None,
force_env_build: bool = False,
environment_name: str | None = None,
result_handler: ResultHandler | None = None,
Expand All @@ -384,6 +404,7 @@ def deploy(
auth=auth,
strategy=strategy,
reset_scale=reset_scale,
attach_to_deployment=attach_to_deployment,
)
return _deploy_from_reference(
client,
Expand Down
1 change: 1 addition & 0 deletions projects/fal/src/fal/cli/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class AppData:
auth: Optional[AuthModeLiteral] = None
deployment_strategy: Optional[DeploymentStrategyLiteral] = None
reset_scale: bool = False
attach_to_deployment: bool | None = None
team: Optional[str] = None
name: Optional[str] = None
options: Options = field(default_factory=Options)
Expand Down
23 changes: 23 additions & 0 deletions projects/fal/src/fal/cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def _deploy(args):
auth=args.auth,
strategy=args.strategy,
reset_scale=args.app_scale_settings,
attach_to_deployment=args.attach_to_deployment,
force_env_build=no_cache,
environment_name=args.env,
)
Expand Down Expand Up @@ -191,6 +192,7 @@ def valid_auth_option(option):
" fal deploy path/to/myfile.py::MyApp\n"
" fal deploy path/to/myfile.py::MyApp --app-name myapp --auth public\n"
" fal deploy my-app\n"
" fal deploy my-app --attach\n"
)

parser = main_subparsers.add_parser(
Expand Down Expand Up @@ -235,6 +237,27 @@ def valid_auth_option(option):
help="Deployment strategy.",
default="rolling",
)
rolling_group = parser.add_argument_group("Rolling deployment")
attach_group = rolling_group.add_mutually_exclusive_group()
attach_group.add_argument(
"--attach",
action="store_true",
dest="attach_to_deployment",
help=(
"Attach to the deployment process. "
"Only applies when --strategy is rolling (the default)."
),
)
attach_group.add_argument(
"--detach",
action="store_false",
dest="attach_to_deployment",
help=(
"Do not attach to the deployment process. "
"Only applies when --strategy is rolling (the default)."
),
)
parser.set_defaults(attach_to_deployment=None)
parser.add_argument(
"--no-scale",
action="store_false",
Expand Down
20 changes: 20 additions & 0 deletions projects/fal/src/fal/cli/deploy_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class DeploymentCheckSummary:
current_auth_mode: str | None
next_auth_mode: str
strategy: str
attach_to_deployment: bool | None
force_env_build: bool
effective_changes: list[DeploymentDiffRow]
effective_scale_values: list[DeploymentDiffRow]
Expand All @@ -112,6 +113,7 @@ def deploy_with_check(
prepare_options_handler: ProgressCallback | None = None,
) -> DeploymentResult:
from fal.api import deploy as deploy_api
from fal.api.deploy import _validate_attach_to_deployment

prepared = deploy_api.prepare_deployment(
client,
Expand All @@ -120,9 +122,11 @@ def deploy_with_check(
auth=args.auth,
strategy=args.strategy,
reset_scale=args.app_scale_settings,
attach_to_deployment=args.attach_to_deployment,
force_env_build=force_env_build,
environment_name=args.env,
)
_validate_attach_to_deployment(prepared.app_data)
production_alias = _get_production_alias(
client,
prepared.loaded.app_name,
Expand Down Expand Up @@ -332,6 +336,7 @@ def _build_deployment_check_summary(
current_auth_mode=production_alias.auth_mode if production_alias else None,
next_auth_mode=prepared.loaded.app_auth or "private",
strategy=prepared.app_data.deployment_strategy or "rolling",
attach_to_deployment=prepared.app_data.attach_to_deployment,
force_env_build=force_env_build,
effective_changes=effective_changes,
effective_scale_values=effective_scale_values,
Expand Down Expand Up @@ -461,6 +466,18 @@ def _render_environment_build_cache_line(force_env_build: bool):
return line


def _render_attach_to_deployment_line(attach_to_deployment: bool | None):
from rich.text import Text

if attach_to_deployment is None:
return None

line = Text()
line.append("Attach to deployment: ", style="bold")
line.append("yes" if attach_to_deployment else "no")
return line


def _render_deployment_check_summary(console, summary: DeploymentCheckSummary) -> None:
from fal.console.rules import print_rule

Expand All @@ -478,6 +495,9 @@ def _render_deployment_check_summary(console, summary: DeploymentCheckSummary) -
console.print(f"[bold]Source object:[/bold] {summary.display_name}")
console.print(_render_auth_line(summary.current_auth_mode, summary.next_auth_mode))
console.print(_render_deployment_strategy_line(summary.strategy))
attach_line = _render_attach_to_deployment_line(summary.attach_to_deployment)
if attach_line is not None:
console.print(attach_line)
console.print(_render_environment_build_cache_line(summary.force_env_build))

if summary.effective_changes:
Expand Down
3 changes: 3 additions & 0 deletions projects/fal/src/fal/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,7 @@ def register(
data_mounts: list[str] | None = None,
entrypoint: str | None = None,
build_environment: bool | None = None,
attach_to_deployment: bool | None = None,
) -> Iterator[RegisterApplicationResult]:
if (
function is None
Expand Down Expand Up @@ -1081,6 +1082,8 @@ def register(
request.private_logs = private_logs
if build_environment is not None:
request.build_environment = build_environment
if attach_to_deployment is not None:
request.attach_to_deployment = attach_to_deployment
for partial_result in self.stub.RegisterApplication(request):
yield from_grpc(partial_result)

Expand Down
120 changes: 120 additions & 0 deletions projects/fal/tests/unit/cli/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ def test_deploy_with_env():
assert args.env == "dev"


def test_deploy_attach_detach_flags():
attach_args = parse_args(["deploy", "myfile.py::MyApp", "--attach"])
assert attach_args.attach_to_deployment is True

detach_args = parse_args(["deploy", "myfile.py::MyApp", "--detach"])
assert detach_args.attach_to_deployment is False

default_args = parse_args(["deploy", "myfile.py::MyApp"])
assert default_args.attach_to_deployment is None


def test_execute_prepared_deployment_reuses_result_handler_for_build_by_default():
from fal.api.deploy import PreparedDeployment, execute_prepared_deployment
from fal.sdk import (
Expand Down Expand Up @@ -152,6 +163,114 @@ def test_execute_prepared_deployment_builds_no_isolate_container():
assert register_kwargs["build_environment"] is False


def test_execute_prepared_deployment_forwards_attach_to_deployment():
from fal.api.deploy import PreparedDeployment, execute_prepared_deployment
from fal.sdk import (
RegisterApplicationResult,
RegisterApplicationResultType,
ServiceURLs,
)

host = MagicMock()
options = Options(environment={"requirements": ["."]})
isolated_function = IsolatedFunction(
host=host,
raw_func=lambda: None,
options=options,
app_name="my-app",
app_auth="public",
)
host.register.return_value = RegisterApplicationResult(
result=RegisterApplicationResultType(application_id="app-id"),
service_urls=ServiceURLs(
playground="https://playground.example",
run="https://run.example",
queue="https://queue.example",
ws="wss://ws.example",
log="https://log.example",
),
)
host.prepare_options.return_value = options
prepared = PreparedDeployment(
host=host,
loaded=SimpleNamespace(
function=isolated_function,
app_name="my-app",
app_auth="public",
source_code=None,
),
app_data=AppData(
deployment_strategy="rolling",
attach_to_deployment=True,
),
display_name="MyApp",
)

execute_prepared_deployment(prepared)

_, register_kwargs = host.register.call_args
assert register_kwargs["attach_to_deployment"] is True


def test_execute_prepared_deployment_rejects_attach_with_recreate_strategy():
from fal.api import FalServerlessError
from fal.api.deploy import PreparedDeployment, execute_prepared_deployment

host = MagicMock()
prepared = PreparedDeployment(
host=host,
loaded=SimpleNamespace(
function=SimpleNamespace(
options=Options(environment={"requirements": ["."]}),
func=lambda: None,
),
app_name="my-app",
app_auth="public",
source_code=None,
),
app_data=AppData(
deployment_strategy="recreate",
attach_to_deployment=True,
),
display_name="MyApp",
)

with pytest.raises(FalServerlessError, match="only applies to rolling deployments"):
execute_prepared_deployment(prepared)


def test_render_attach_to_deployment_line():
from fal.cli.deploy_check import _render_attach_to_deployment_line

assert _render_attach_to_deployment_line(None) is None
assert _render_attach_to_deployment_line(True).plain == "Attach to deployment: yes"
assert _render_attach_to_deployment_line(False).plain == "Attach to deployment: no"


def test_build_deployment_check_summary_includes_attach_to_deployment():
prepared = _prepared_deployment(reset_scale=False)
prepared = SimpleNamespace(
loaded=prepared.loaded,
display_name=prepared.display_name,
environment_name=prepared.environment_name,
app_data=AppData(
reset_scale=False,
deployment_strategy="rolling",
name="my-app",
attach_to_deployment=True,
),
)

summary = _build_deployment_check_summary(
prepared,
None,
source="flag",
force_env_build=False,
)

assert summary.attach_to_deployment is True


def test_deploy_with_env_and_other_options():
args = parse_args(
[
Expand Down Expand Up @@ -301,6 +420,7 @@ def mock_args(
args.env = env
args.check = False
args.yes = False
args.attach_to_deployment = None
args.host = "my-host"

return args
Expand Down
Loading
Loading