diff --git a/news/+deploy-shim.misc.md b/news/+deploy-shim.misc.md new file mode 100644 index 00000000000..173f7edb1d8 --- /dev/null +++ b/news/+deploy-shim.misc.md @@ -0,0 +1 @@ +`reflex deploy` is now a thin shim over the hosting CLI's deploy: it declares only the framework-owned flags (`--exclude-from-backend`, `--ssr`) and adopts every hosting flag from `reflex_cli.v2.deploy_options`, forwarding parsed values as keyword arguments. New hosting deploy flags therefore ship with a hosting-cli release alone, without a reflex release. A hosting CLI too old to own that module keeps working: the flags fall back to a baseline copy in the framework, which is removed once the dependency floor moves to a release that ships the module. diff --git a/packages/reflex-hosting-cli/news/+deploy-options-export.feature.md b/packages/reflex-hosting-cli/news/+deploy-options-export.feature.md new file mode 100644 index 00000000000..8ed090377c5 --- /dev/null +++ b/packages/reflex-hosting-cli/news/+deploy-options-export.feature.md @@ -0,0 +1 @@ +The deploy command's hosting-owned click options are now exported from `reflex_cli.v2.deploy_options.deploy_options()`. `reflex deploy` adopts them from the installed hosting CLI and forwards parsed values as keyword arguments, so adding a hosting flag is one change here and ships with a hosting-cli release alone — no framework release required. diff --git a/packages/reflex-hosting-cli/news/+gcp-service-name-from-hostname.feature.md b/packages/reflex-hosting-cli/news/+gcp-service-name-from-hostname.feature.md new file mode 100644 index 00000000000..383bbc10d14 --- /dev/null +++ b/packages/reflex-hosting-cli/news/+gcp-service-name-from-hostname.feature.md @@ -0,0 +1 @@ +On the deploy that first lands an app on GCP, `--hostname` now doubles as the app's Cloud Run service name, so the service in the customer's console reads like the app's URL instead of `app-`. A hostname the service-name grammar refuses (leading digit, over 49 characters, or the reserved `app-` shape) is skipped with a note and the server generates a name from the app name; later GCP deploys never send one, since the name is pinned to the live service. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 49ba13f263c..01192ad718c 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -1033,6 +1033,7 @@ def set_app_provider( provider: str, client: AuthenticatedClient, provider_account_id: str | None = None, + service_name: str | None = None, ) -> str: """Choose which hosting platform an app deploys to. @@ -1049,6 +1050,9 @@ def set_app_provider( through (GCP only). None keeps the connection the app already has when it stays on GCP, and means the org's default connection when GCP is first chosen. + service_name: The Cloud Run service name the app deploys as (GCP only). + None keeps the app's current name, or lets the server mint one from + the app name; the server refuses a change once the app has deployed. Returns: The provider now set on the app, or a ``"... failed: ..."`` string on @@ -1065,6 +1069,8 @@ def set_app_provider( payload: dict[str, Any] = {"provider": provider} if provider_account_id is not None: payload["provider_account_id"] = provider_account_id + if service_name is not None: + payload["service_name"] = service_name response = httpx.post( urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/provider"), json=payload, diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 68f9958855d..22cc5d528d3 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -7,8 +7,10 @@ import json import logging import os +import re import shutil import tempfile +import uuid from collections.abc import Callable, Iterator from pathlib import Path from typing import Any @@ -129,8 +131,62 @@ def _resolve_gcp_connection( return match +# Cloud Run's service name grammar: lowercase, starts with a letter, ends +# alphanumeric, at most 49 characters. Mirrors the server's validation so a +# hostname that cannot be a service name is skipped here (the server mints one) +# instead of failing the deploy. +_GCP_SERVICE_NAME_RE = re.compile(r"^[a-z]([-a-z0-9]*[a-z0-9])?$") +_GCP_SERVICE_NAME_MAX_LENGTH = 49 + + +def _gcp_service_name_from_hostname(hostname: str | None) -> str | None: + """The --hostname value as a Cloud Run service name, if it can be one. + + On the first deploy to GCP the hostname doubles as the app's Cloud Run + service name, so the service in the customer's console reads like the app's + URL. A hostname the service-name grammar refuses (too long, leading digit, + or the reserved ``app-`` shape) is skipped with a note rather than + failing the deploy — the server then mints a name from the app name. + + Args: + hostname: The ``--hostname`` value, if the user passed one. + + Returns: + The service name to request, or None to let the server choose. + + """ + if not hostname: + return None + name = hostname.strip().lower() + reserved = False + if name.startswith("app-"): + try: + uuid.UUID(name.removeprefix("app-")) + reserved = True + except ValueError: + reserved = False + if ( + not name + or len(name) > _GCP_SERVICE_NAME_MAX_LENGTH + or not _GCP_SERVICE_NAME_RE.match(name) + or reserved + ): + logger.info( + f"The hostname '{hostname}' cannot be used as the Cloud Run service " + "name (lowercase letters, digits and hyphens, starting with a " + f"letter, at most {_GCP_SERVICE_NAME_MAX_LENGTH} characters); one " + "will be generated from the app name." + ) + return None + return name + + def _pin_app_provider( - app: dict[str, Any], target: str, connection: dict[str, Any] | None, client: Any + app: dict[str, Any], + target: str, + connection: dict[str, Any] | None, + client: Any, + service_name: str | None = None, ) -> None: """Write the app's provider (and connection), aborting the deploy on refusal. @@ -139,6 +195,8 @@ def _pin_app_provider( target: The backend provider value to pin. connection: The GCP connection to deploy through, if one was named. client: The authenticated client. + service_name: The Cloud Run service name to request (GCP only); None + keeps the app's current name or lets the server mint one. Raises: Exit: If the server refused the change. @@ -151,6 +209,7 @@ def _pin_app_provider( target, client=client, provider_account_id=str(connection["id"]) if connection else None, + service_name=service_name, ) if isinstance(result, str) and result.startswith("set provider failed"): logger.error(result) @@ -164,6 +223,7 @@ def _resolve_deploy_provider( app_was_created: bool, client: Any, gcp_connection: str | None = None, + hostname: str | None = None, ) -> str | None: """Resolve and pin the hosting provider for this deploy. @@ -183,6 +243,10 @@ def _resolve_deploy_provider( connections to deploy through. Omitted leaves the app on the connection it already has, or the org's default the first time it targets GCP. + hostname: The ``--hostname`` value. When this deploy is what first + lands the app on GCP, it doubles as the requested Cloud Run service + name; on later GCP deploys the name is already pinned to the live + service, so it is not sent. Returns: The backend provider value in effect (Reflex Cloud's default or GCP), or @@ -254,9 +318,19 @@ def _resolve_deploy_provider( logger.info("Deployment cancelled.") raise click.exceptions.Exit(0) - _pin_app_provider(app, target, connection, client) + # Only this pin — the one that first lands the app on GCP — carries a + # service name. It is the moment the server would mint one, and the only + # time a request cannot collide with a name already serving traffic. + service_name = ( + _gcp_service_name_from_hostname(hostname) + if target == hosting.PROVIDER_GCP + else None + ) + _pin_app_provider(app, target, connection, client, service_name=service_name) via = f" through connection '{connection.get('name')}'" if connection else "" logger.info(f"Deploying to {hosting.provider_display_name(target)}{via}.") + if service_name: + logger.info(f"Requested Cloud Run service name '{service_name}'.") return target @@ -484,7 +558,9 @@ def deploy( project: The project to deploy to. envs: The environment variables to set. vmtype: The VM type to allocate. - hostname: The hostname to use for the frontend. + hostname: The hostname to use for the frontend. On the deploy that + first lands the app on GCP it also names the app's Cloud Run + service, when the service-name grammar allows it. interactive: Whether to use interactive mode. envfile: The path to an env file to use. Will override any envs set manually. loglevel: The log level to use. @@ -749,6 +825,7 @@ def deploy( app_was_created=app_was_created, client=authenticated_client, gcp_connection=gcp_connection, + hostname=hostname, ) # A destructive provider switch on an already-deployed app tears its old # resources down; remember what to restore to if a later step fails. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy_options.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy_options.py new file mode 100644 index 00000000000..84b4c657022 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy_options.py @@ -0,0 +1,135 @@ +"""The deploy command's click options, owned by the hosting CLI. + +``reflex deploy`` is a thin shim: it declares only the options the framework +itself consumes (export/compile concerns) and adopts everything else from +:func:`deploy_options`, forwarding the parsed values into +:func:`reflex_cli.v2.cli.deploy` as keyword arguments. Adding a hosting flag is +therefore one change here — an option whose name matches the ``deploy()`` +parameter it feeds — and ships with a hosting-cli release alone, no framework +release required. +""" + +from __future__ import annotations + +import click + +DEPLOY_STRATEGIES = ["immediate", "rolling", "bluegreen", "canary"] + + +def deploy_options() -> list[click.Parameter]: + """The hosting-owned click options for the deploy command. + + Every option's parameter name matches the keyword argument of + :func:`reflex_cli.v2.cli.deploy` it is forwarded to. + + Returns: + The options, in the order they should appear in ``--help``. + + """ + return [ + click.Option( + ["--app-name", "app_name"], + help="The name of the app to deploy.", + ), + click.Option( + ["--app-id", "app_id"], + help="The ID of the app to deploy.", + ), + click.Option( + ["-r", "--region", "regions"], + multiple=True, + help="The regions to deploy to. `reflex cloud regions` For multiple " + "envs, repeat this option, e.g. --region sjc --region iad", + ), + click.Option( + ["--env", "envs"], + multiple=True, + help="The environment variables to set: =. For multiple " + "envs, repeat this option, e.g. --env k1=v2 --env k2=v2.", + ), + click.Option( + ["--vmtype", "vmtype"], + help="Vm type id. Run `reflex cloud vmtypes` to get options.", + ), + click.Option( + ["--min-instances", "min_instances"], + type=int, + help="The minimum number of instances to keep running. Left " + "unchanged when omitted. Only supported on apps deployed to " + "Google Cloud.", + ), + click.Option( + ["--max-instances", "max_instances"], + type=int, + help="The maximum number of instances to scale out to. Left " + "unchanged when omitted. Only supported on apps deployed to " + "Google Cloud.", + ), + click.Option( + ["--hostname", "hostname"], + help="The hostname of the frontend. On the deploy that first lands " + "the app on GCP, it also names the app's Cloud Run service.", + ), + click.Option( + ["--provider", "provider"], + help="The hosting provider to deploy to: 'reflex-cloud' (default) " + "or 'gcp' (a GCP account connected to your org, Enterprise tier). " + "When omitted and GCP is connected, you'll be prompted in " + "interactive mode. Deploys through Reflex Cloud either way; for an " + "unmanaged deploy run under your own gcloud credentials, see " + "`reflex cloud gcp-standalone`.", + ), + click.Option( + ["--gcp-connection", "gcp_connection"], + help="Which of your organization's GCP connections to deploy " + "through, by name. Run `reflex cloud providers connections` to " + "list them. Only valid with --provider gcp; omitted keeps the app " + "on the connection it already has, or your organization's default " + "the first time it deploys to GCP.", + ), + click.Option( + ["--full-deploy/--no-full-deploy", "full_deploy"], + default=None, + help="Serve the frontend from the provider's own container, on the " + "same origin as the backend, instead of Reflex's CDN. GCP only, " + "Enterprise tier. Omitted leaves the app's hosting mode unchanged; " + "changing it stops a running app so this deploy brings it back up " + "in the new mode.", + ), + click.Option( + ["--strategy", "strategy"], + type=click.Choice(DEPLOY_STRATEGIES), + help="How the new version rolls out. Defaults to the app's last " + "strategy, or 'immediate'.", + ), + click.Option( + ["--description", "deployment_description"], + help="An optional note recorded on this deployment and shown in " + "`reflex cloud apps history`.", + ), + click.Option( + ["--interactive/--no-interactive", "interactive"], + default=True, + help="Whether to list configuration options and ask for confirmation.", + ), + click.Option( + ["--envfile", "envfile"], + help="The path to an env file to use. Will override any envs set manually.", + ), + click.Option( + ["--project", "project"], + help="project id to deploy to", + ), + click.Option( + ["--project-name", "project_name"], + help="The name of the project to deploy to.", + ), + click.Option( + ["--token", "token"], + help="token to use for auth", + ), + click.Option( + ["--config-path", "--config", "config_path"], + help="path to the config file", + ), + ] diff --git a/reflex/reflex.py b/reflex/reflex.py index 3d44a60a34d..a800f04d39a 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -5,7 +5,7 @@ import logging from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click from reflex_base import constants @@ -19,6 +19,97 @@ logger = logging.getLogger(__name__) +try: + from reflex_cli.v2.deploy_options import deploy_options +except ImportError: + # The installed hosting CLI predates the module that owns the deploy flags. + # Tolerated rather than raised: this import runs for every reflex command, + # and the declared floor (reflex-hosting-cli >= 0.1.66) admits such a + # version, so `reflex deploy` falls back to the baseline below and the rest + # of the CLI is unaffected. + deploy_options = None + + +def _baseline_deploy_options() -> list[click.Parameter]: + """The hosting deploy flags as of this reflex release. + + Used only when the installed hosting CLI is too old to own the list + itself. `pip install -U reflex` can leave an already-satisfied + reflex-hosting-cli untouched, and the declared floor admits one without + `reflex_cli.v2.deploy_options`, so that is a supported install and + `reflex deploy` has to keep parsing its own flags there. + + Delete this together with the floor bump to the first hosting-cli release + that ships `deploy_options`; until then + `test_the_baseline_matches_the_hosting_clis_own_list` holds the two equal. + + Returns: + The options, matching `deploy_options()` name for name. + + """ + return [ + click.Option(["--app-name", "app_name"], help="The name of the app to deploy."), + click.Option(["--app-id", "app_id"], help="The ID of the app to deploy."), + click.Option( + ["-r", "--region", "regions"], + multiple=True, + help="The regions to deploy to.", + ), + click.Option( + ["--env", "envs"], + multiple=True, + help="The environment variables to set: =.", + ), + click.Option(["--vmtype", "vmtype"], help="Vm type id."), + click.Option( + ["--min-instances", "min_instances"], + type=int, + help="The minimum number of instances to keep running.", + ), + click.Option( + ["--max-instances", "max_instances"], + type=int, + help="The maximum number of instances to scale out to.", + ), + click.Option(["--hostname", "hostname"], help="The hostname of the frontend."), + click.Option(["--provider", "provider"], help="The hosting provider."), + click.Option( + ["--gcp-connection", "gcp_connection"], + help="Which GCP connection to deploy through.", + ), + click.Option( + ["--full-deploy/--no-full-deploy", "full_deploy"], + default=None, + help="Serve the frontend from the provider's own container.", + ), + click.Option( + ["--strategy", "strategy"], + type=click.Choice(["immediate", "rolling", "bluegreen", "canary"]), + help="How the new version rolls out.", + ), + click.Option( + ["--description", "deployment_description"], + help="An optional note recorded on this deployment.", + ), + click.Option( + ["--interactive/--no-interactive"], + default=True, + help="Whether to ask for confirmation.", + ), + click.Option(["--envfile", "envfile"], help="The path to an env file to use."), + click.Option(["--project", "project"], help="project id to deploy to"), + click.Option( + ["--project-name", "project_name"], + help="The name of the project to deploy to.", + ), + click.Option(["--token", "token"], help="token to use for auth"), + click.Option( + ["--config-path", "--config", "config_path"], + help="path to the config file", + ), + ] + + if TYPE_CHECKING: from typing import Literal @@ -814,107 +905,6 @@ def makemigrations(message: str | None): @cli.command() @log_options -@click.option( - "--app-name", - help="The name of the app to deploy.", -) -@click.option( - "--app-id", - help="The ID of the app to deploy.", -) -@click.option( - "-r", - "--region", - multiple=True, - help="The regions to deploy to. `reflex cloud regions` For multiple envs, repeat this option, e.g. --region sjc --region iad", -) -@click.option( - "--env", - multiple=True, - help="The environment variables to set: =. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.", -) -@click.option( - "--vmtype", - help="Vm type id. Run `reflex cloud vmtypes` to get options.", -) -@click.option( - "--min-instances", - type=int, - help="The minimum number of instances to keep running. Left unchanged when " - "omitted. Only supported on apps deployed to Google Cloud.", -) -@click.option( - "--max-instances", - type=int, - help="The maximum number of instances to scale out to. Left unchanged when " - "omitted. Only supported on apps deployed to Google Cloud.", -) -@click.option( - "--hostname", - help="The hostname of the frontend.", -) -@click.option( - "--provider", - help="The hosting provider to deploy to: 'reflex-cloud' (default) or 'gcp' " - "(a GCP account connected to your org, Enterprise tier). When omitted and " - "GCP is connected, you'll be prompted in interactive mode. Deploys through " - "Reflex Cloud either way; for an unmanaged deploy run under your own " - "gcloud credentials, see `reflex cloud gcp-standalone`.", -) -@click.option( - "--gcp-connection", - help="Which of your organization's GCP connections to deploy through, by " - "name. Run `reflex cloud providers connections` to list them. Only valid " - "with --provider gcp; omitted keeps the app on the connection it already " - "has, or your organization's default the first time it deploys to GCP.", -) -@click.option( - "--full-deploy/--no-full-deploy", - "full_deploy", - default=None, - help="Serve the frontend from the provider's own container, on the same " - "origin as the backend, instead of Reflex's CDN. GCP only, Enterprise " - "tier. Omitted leaves the app's hosting mode unchanged; changing it stops " - "a running app so this deploy brings it back up in the new mode.", -) -@click.option( - "--strategy", - type=click.Choice(["immediate", "rolling", "bluegreen", "canary"]), - help="How the new version rolls out. Defaults to the app's last strategy, " - "or 'immediate'.", -) -@click.option( - "--description", - help="An optional note recorded on this deployment and shown in " - "`reflex cloud apps history`.", -) -@click.option( - "--interactive/--no-interactive", - is_flag=True, - default=True, - help="Whether to list configuration options and ask for confirmation.", -) -@click.option( - "--envfile", - help="The path to an env file to use. Will override any envs set manually.", -) -@click.option( - "--project", - help="project id to deploy to", -) -@click.option( - "--project-name", - help="The name of the project to deploy to.", -) -@click.option( - "--token", - help="token to use for auth", -) -@click.option( - "--config-path", - "--config", - help="path to the config file", -) @click.option( "--exclude-from-backend", "backend_excluded_dirs", @@ -931,27 +921,9 @@ def makemigrations(message: str | None): help="Whether to enable server side rendering for the frontend.", ) def deploy( - app_name: str | None, - app_id: str | None, - region: tuple[str, ...], - env: tuple[str], - vmtype: str | None, - min_instances: int | None, - max_instances: int | None, - hostname: str | None, - provider: str | None, - gcp_connection: str | None, - full_deploy: bool | None, - strategy: str | None, - description: str | None, - interactive: bool, - envfile: str | None, - project: str | None, - project_name: str | None, - token: str | None, - config_path: str | None, backend_excluded_dirs: tuple[Path, ...] = (), ssr: bool = True, + **kwargs, ): """Deploy the app to the Reflex hosting service.""" from reflex_cli.utils import dependency @@ -963,7 +935,7 @@ def deploy( config = get_config() - app_name = app_name or config.app_name + kwargs["app_name"] = kwargs.get("app_name") or config.app_name check_version() @@ -976,7 +948,7 @@ def deploy( # Only check requirements if interactive. # There is user interaction for requirements update. - if interactive: + if kwargs.get("interactive", True): dependency.check_requirements() prerequisites.assert_in_reflex_dir() @@ -986,9 +958,14 @@ def deploy( _init(name=config.app_name) prerequisites.check_latest_package_version(constants.ReflexHostingCLI.MODULE_NAME) + # Hosting flags pass through untouched (multi-value options as lists), so a + # new hosting-cli deploy option ships without a framework release: the + # option list itself comes from deploy_options() below. + forwarded: dict[str, Any] = { + key: list(value) if isinstance(value, tuple) else value + for key, value in kwargs.items() + } hosting_cli.deploy( - app_name=app_name, - app_id=app_id, export_fn=( lambda zip_dest_dir, api_url, deploy_url, frontend, backend, upload_db, zipping: ( export_utils.export( @@ -1005,27 +982,21 @@ def deploy( ) ) ), - regions=list(region), - envs=list(env), - vmtype=vmtype, - min_instances=min_instances, - max_instances=max_instances, - envfile=envfile, - hostname=hostname, - interactive=interactive, loglevel=config.loglevel, - token=token, - project=project, - project_name=project_name, - provider=provider, - gcp_connection=gcp_connection, - full_deploy=full_deploy, - strategy=strategy, - deployment_description=description, - **({"config_path": config_path} if config_path is not None else {}), + **forwarded, ) +# The hosting-owned deploy flags are adopted from the installed hosting CLI, so +# they version with it: `reflex deploy` declares only what the framework itself +# consumes (export/compile options above) and forwards the rest as kwargs. A +# hosting CLI older than that module falls back to the baseline, which keeps +# every version the floor admits able to deploy. +deploy.params[:0] = ( + deploy_options() if deploy_options is not None else _baseline_deploy_options() +) + + @cli.command() @log_options @click.argument("new_name") diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 07783941567..4367e37d28c 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -498,6 +498,20 @@ def test_set_app_provider_forwards_connection(mocker: MockerFixture): } +def test_set_app_provider_forwards_service_name(mocker: MockerFixture): + """A requested Cloud Run service name rides along as service_name.""" + mock_post = mocker.patch( + "httpx.post", return_value=_ok(mocker, {"provider": "gcp"}) + ) + assert set_app_provider( + "app-1", "gcp", _CLIENT, service_name="sales-dashboard" + ) == ("gcp") + assert mock_post.call_args.kwargs["json"] == { + "provider": "gcp", + "service_name": "sales-dashboard", + } + + def test_set_app_full_deploy_success(mocker: MockerFixture): """The mode change posts to the app's full_deploy endpoint.""" mock_post = mocker.patch( diff --git a/tests/units/reflex_cli/v2/test_cli.py b/tests/units/reflex_cli/v2/test_cli.py index 0a24eda93e1..9ba6a38479f 100644 --- a/tests/units/reflex_cli/v2/test_cli.py +++ b/tests/units/reflex_cli/v2/test_cli.py @@ -1463,10 +1463,80 @@ def test_resolve_deploy_provider_explicit_gcp_switches(mocker: MockFixture): ) assert result == "gcp" mock_set.assert_called_once_with( - "app-1", "gcp", client=client, provider_account_id=None + "app-1", "gcp", client=client, provider_account_id=None, service_name=None ) +def test_resolve_deploy_provider_hostname_names_the_gcp_service( + mocker: MockFixture, +): + """On the deploy that first lands on GCP, --hostname doubles as the service name.""" + client = hosting.AuthenticatedClient(token="t", validated_data={}) + mock_set = mocker.patch( + "reflex_cli.utils.hosting.set_app_provider", return_value="gcp" + ) + app = {"id": "app-1", "name": "myapp", "provider": "fly"} + result = cli._resolve_deploy_provider( + app, + "gcp", + interactive=False, + app_was_created=True, + client=client, + hostname="Sales-Dashboard", + ) + assert result == "gcp" + # Lowercased into the service-name grammar before it is sent. + mock_set.assert_called_once_with( + "app-1", + "gcp", + client=client, + provider_account_id=None, + service_name="sales-dashboard", + ) + + +def test_resolve_deploy_provider_unusable_hostname_lets_the_server_mint( + mocker: MockFixture, +): + """A hostname the service-name grammar refuses is skipped, not fatal.""" + client = hosting.AuthenticatedClient(token="t", validated_data={}) + mock_set = mocker.patch( + "reflex_cli.utils.hosting.set_app_provider", return_value="gcp" + ) + app = {"id": "app-1", "name": "myapp", "provider": "fly"} + result = cli._resolve_deploy_provider( + app, + "gcp", + interactive=False, + app_was_created=True, + client=client, + # Valid DNS label, invalid Cloud Run service name (leading digit). + hostname="2048game", + ) + assert result == "gcp" + mock_set.assert_called_once_with( + "app-1", "gcp", client=client, provider_account_id=None, service_name=None + ) + + +def test_gcp_service_name_from_hostname_grammar(): + """Only hostnames Cloud Run would accept as service names pass through.""" + assert cli._gcp_service_name_from_hostname("sales-dashboard") == "sales-dashboard" + assert cli._gcp_service_name_from_hostname("MyApp") == "myapp" + assert cli._gcp_service_name_from_hostname(None) is None + assert cli._gcp_service_name_from_hostname("") is None + assert cli._gcp_service_name_from_hostname("2048game") is None + assert cli._gcp_service_name_from_hostname("-dash") is None + assert cli._gcp_service_name_from_hostname("dash-") is None + assert cli._gcp_service_name_from_hostname("a" * 50) is None + # The derived-name namespace of apps that store no name is reserved. + assert ( + cli._gcp_service_name_from_hostname("app-8b2f4a1c-1234-5678-9abc-def012345678") + is None + ) + assert cli._gcp_service_name_from_hostname("app-metrics") == "app-metrics" + + def test_resolve_deploy_provider_reflex_cloud_no_switch(mocker: MockFixture): """--provider reflex-cloud on a fly app is a no-op (already Reflex Cloud).""" client = hosting.AuthenticatedClient(token="t", validated_data={}) @@ -1616,7 +1686,7 @@ def test_resolve_deploy_provider_named_connection_is_pinned(mocker: MockFixture) assert result == "gcp" mock_set.assert_called_once_with( - "app-1", "gcp", client=client, provider_account_id="conn-2" + "app-1", "gcp", client=client, provider_account_id="conn-2", service_name=None ) @@ -1645,7 +1715,7 @@ def test_resolve_deploy_provider_repoints_without_a_provider_switch( assert result == "gcp" mock_set.assert_called_once_with( - "app-1", "gcp", client=client, provider_account_id="conn-2" + "app-1", "gcp", client=client, provider_account_id="conn-2", service_name=None ) diff --git a/tests/units/reflex_cli/v2/test_deploy_options.py b/tests/units/reflex_cli/v2/test_deploy_options.py new file mode 100644 index 00000000000..b81b52cd437 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_deploy_options.py @@ -0,0 +1,81 @@ +"""Tests for the hosting-owned deploy option list.""" + +import inspect + +import click +from reflex_cli.v2 import cli +from reflex_cli.v2.deploy_options import deploy_options + +# Parameters the `reflex deploy` shim owns itself; a hosting option adopting +# one of these names would shadow the framework's own flag. +FRAMEWORK_OWNED = {"backend_excluded_dirs", "ssr", "loglevel", "log_json"} + + +def test_every_option_feeds_a_deploy_parameter(): + """Each option's name must be an explicit keyword of cli.deploy. + + The shim forwards parsed values as keyword arguments; a name that only + lands in ``**kwargs`` is silently dropped by the implementation, so the + option would parse and then do nothing. + """ + signature = inspect.signature(cli.deploy) + # Only these two kinds can receive a forwarded keyword: `*args` and a + # positional-only parameter would let an option pass this check and then + # be dropped into `**kwargs` at the call, which is the failure it exists + # to catch. + forwardable = { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } + explicit = { + name + for name, parameter in signature.parameters.items() + if parameter.kind in forwardable + } + for option in deploy_options(): + assert option.name in explicit, ( + f"deploy option '{option.name}' has no matching cli.deploy parameter" + ) + + +def test_option_names_are_unique_and_do_not_shadow_the_shim(): + """No duplicate names, and none colliding with the framework's own flags.""" + names = [option.name for option in deploy_options()] + assert len(names) == len(set(names)) + assert not set(names) & FRAMEWORK_OWNED + + +def test_options_parse_a_representative_invocation(): + """The raw Option objects parse flags into the parameter names deploy uses.""" + command = click.Command( + "deploy-options-probe", params=deploy_options(), callback=lambda **kw: kw + ) + with click.Context(command) as ctx: + args = [ + "--app-name", + "myapp", + "-r", + "sjc", + "--region", + "iad", + "--env", + "K=V", + "--description", + "first cut", + "--hostname", + "sales-dashboard", + "--no-interactive", + "--full-deploy", + "--config", + "cloud.yml", + ] + command.parse_args(ctx, args) + values = ctx.params + assert values["app_name"] == "myapp" + assert values["regions"] == ("sjc", "iad") + assert values["envs"] == ("K=V",) + assert values["deployment_description"] == "first cut" + assert values["hostname"] == "sales-dashboard" + assert values["interactive"] is False + assert values["full_deploy"] is True + assert values["config_path"] == "cloud.yml" diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py new file mode 100644 index 00000000000..0642f2760e8 --- /dev/null +++ b/tests/units/test_reflex.py @@ -0,0 +1,153 @@ +"""Tests for the top-level CLI entry points in reflex/reflex.py.""" + +import builtins +import importlib +import os + +import pytest +from click.testing import CliRunner +from pytest_mock import MockerFixture + +from reflex.reflex import cli + +runner = CliRunner() + + +@pytest.fixture +def isolated_environ(mocker: MockerFixture): + """Restore os.environ after a test that runs a command body. + + ``environment.*.set()`` writes straight into ``os.environ``, so invoking + the deploy shim leaks REFLEX_SSR and REFLEX_COMPILE_CONTEXT into every + later test in the process (which reads as an app harness failing to reset + a prod env mode, several files away). + """ + mocker.patch.dict(os.environ) + + +def test_deploy_adopts_the_hosting_option_list(): + """`reflex deploy` carries every hosting-owned option by reference. + + The shim owns only the export/compile flags; the rest come from the + installed hosting CLI so a new hosting flag ships without a framework + release. + """ + from reflex_cli.v2.deploy_options import deploy_options + + deploy = cli.commands["deploy"] + param_names = {param.name for param in deploy.params} + for option in deploy_options(): + assert option.name in param_names + # The framework's own flags are still there. + assert "backend_excluded_dirs" in param_names + assert "ssr" in param_names + + +def test_deploy_help_renders_hosting_options(): + """--help lists the adopted flags, proving they parse as real options.""" + result = runner.invoke(cli, ["deploy", "--help"]) + assert result.exit_code == 0 + for flag in ("--hostname", "--provider", "--gcp-connection", "--strategy"): + assert flag in result.output + + +def test_the_baseline_matches_the_hosting_clis_own_list(): + """The temporary fallback must not drift from the list it stands in for. + + Two declarations of the same flags is the cost of supporting a hosting CLI + that predates the module; this keeps them the same set, so the flags a + deploy accepts do not depend on which one was used. + """ + from reflex_cli.v2.deploy_options import deploy_options + + from reflex.reflex import _baseline_deploy_options + + baseline = {option.name for option in _baseline_deploy_options()} + assert baseline == {option.name for option in deploy_options()} + + +def test_an_older_hosting_cli_can_still_deploy( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, isolated_environ: None +): + """A hosting CLI without deploy_options keeps a working deploy. + + `pip install -U reflex` can leave an already-satisfied reflex-hosting-cli + untouched, and the declared floor admits one without the module, so that + is a supported install: the flags fall back to the baseline rather than + the command losing them. Reloaded with the module hidden, since which + list is used is decided at import time. + """ + real_import = builtins.__import__ + + def hide_deploy_options(name, *args, **kwargs): + if name == "reflex_cli.v2.deploy_options": + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", hide_deploy_options) + module = importlib.reload(importlib.import_module("reflex.reflex")) + try: + assert module.deploy_options is None + mocker.patch("reflex_cli.v2.deployments.check_version") + mocker.patch("reflex_cli.utils.dependency.check_requirements") + mocker.patch("reflex.utils.prerequisites.assert_in_reflex_dir") + mocker.patch("reflex.utils.prerequisites.needs_reinit", return_value=False) + mocker.patch("reflex.utils.prerequisites.check_latest_package_version") + hosting_deploy = mocker.patch("reflex_cli.v2.cli.deploy") + + result = CliRunner().invoke( + module.cli, + ["deploy", "--hostname", "sales-dashboard", "--no-interactive"], + ) + + assert result.exit_code == 0, result.output + assert hosting_deploy.call_args.kwargs["hostname"] == "sales-dashboard" + finally: + monkeypatch.setattr(builtins, "__import__", real_import) + importlib.reload(module) + + +def test_deploy_forwards_hosting_flags_as_kwargs( + mocker: MockerFixture, isolated_environ: None +): + """Parsed hosting flags reach hosting_cli.deploy under the lib's own names.""" + mocker.patch("reflex_cli.v2.deployments.check_version") + mocker.patch("reflex_cli.utils.dependency.check_requirements") + mocker.patch("reflex.utils.prerequisites.assert_in_reflex_dir") + mocker.patch("reflex.utils.prerequisites.needs_reinit", return_value=False) + mocker.patch("reflex.utils.prerequisites.check_latest_package_version") + hosting_deploy = mocker.patch("reflex_cli.v2.cli.deploy") + + result = runner.invoke( + cli, + [ + "deploy", + "--app-name", + "myapp", + "--region", + "sjc", + "--region", + "iad", + "--hostname", + "sales-dashboard", + "--provider", + "gcp", + "--description", + "first cut", + "--no-interactive", + ], + ) + + assert result.exit_code == 0, result.output + kwargs = hosting_deploy.call_args.kwargs + assert kwargs["app_name"] == "myapp" + # Multi-value flags arrive as lists, matching the pre-shim behavior. + assert kwargs["regions"] == ["sjc", "iad"] + assert kwargs["hostname"] == "sales-dashboard" + assert kwargs["provider"] == "gcp" + assert kwargs["deployment_description"] == "first cut" + assert kwargs["interactive"] is False + assert callable(kwargs["export_fn"]) + # Framework-owned flags are consumed by the shim, not forwarded. + assert "ssr" not in kwargs + assert "backend_excluded_dirs" not in kwargs