Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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-<uuid>`. A hostname the service-name grammar refuses (leading digit, over 49 characters, or the reserved `app-<uuid>` 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.
6 changes: 6 additions & 0 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Comment thread
Kastier1 marked this conversation as resolved.
response = httpx.post(
urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/provider"),
json=payload,
Expand Down
83 changes: 80 additions & 3 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<uuid>`` 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()
Comment thread
Kastier1 marked this conversation as resolved.
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.

Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/reflex-hosting-cli/src/reflex_cli/v2/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions tests/units/reflex_cli/utils/test_hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
110 changes: 107 additions & 3 deletions tests/units/reflex_cli/v2/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={})
Expand Down Expand Up @@ -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
)


Expand Down Expand Up @@ -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
)


Expand Down
Loading