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 6663a03e952..dc63d538d0f 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -1152,6 +1152,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. @@ -1168,6 +1169,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 @@ -1184,6 +1188,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 b4f8e238326..864b31cceba 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 @@ -128,8 +130,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. @@ -138,6 +194,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. @@ -150,6 +208,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) @@ -163,6 +222,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. @@ -182,6 +242,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 @@ -253,9 +317,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 @@ -483,7 +557,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. @@ -748,6 +824,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.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py index 5e19452f6e2..acb514e493c 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py @@ -59,7 +59,8 @@ ) @click.option( "--hostname", - help="The hostname of the frontend.", + 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", diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 5633b878045..3db43bfca8e 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -694,6 +694,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..8a850882911 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,41 @@ 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 + ) + + +def test_resolve_deploy_provider_gcp_redeploy_keeps_the_pinned_name( + mocker: MockFixture, +): + """An app already on GCP never re-sends a service name. + + The name is pinned to a live service by then, so a --hostname on a later + deploy must not reach the server as a rename request. + """ + client = hosting.AuthenticatedClient(token="t", validated_data={}) + mocker.patch( + "reflex_cli.utils.hosting.list_gcp_connections", + return_value=[{"id": "conn-2", "name": "eu-prod"}], + ) + mock_set = mocker.patch( + "reflex_cli.utils.hosting.set_app_provider", return_value="gcp" + ) + app = {"id": "app-1", "name": "myapp", "provider": "gcp"} + + result = cli._resolve_deploy_provider( + app, + "gcp", + interactive=False, + app_was_created=False, + client=client, + gcp_connection="eu-prod", + hostname="sales-dashboard", + ) + + assert result == "gcp" + mock_set.assert_called_once_with( + "app-1", "gcp", client=client, provider_account_id="conn-2", service_name=None )