Skip to content
Open
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
1 change: 1 addition & 0 deletions news/+deploy-shim.misc.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
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 @@ -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.

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

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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
135 changes: 135 additions & 0 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/deploy_options.py
Original file line number Diff line number Diff line change
@@ -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: <key>=<value>. 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",
),
]
Loading
Loading