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
9 changes: 9 additions & 0 deletions mkdocs/docs/concepts/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,15 @@ gcloud projects list --format="json(projectId)"
compute.regionUrlMaps.use
```

If you also plan to use gateways with `certificate: { type: gcp-cm }`, additional permissions are required:

```
certificatemanager.certs.use
compute.regionTargetHttpsProxies.create
compute.regionTargetHttpsProxies.delete
compute.regionTargetHttpsProxies.use
```

If you plan to use TPUs, additional permissions are required:

```
Expand Down
10 changes: 7 additions & 3 deletions mkdocs/docs/concepts/gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ Setting `load_balancer: { type: alb }` provisions an Application Load Balancer (
replicas: 2
load_balancer:
type: alb
certificate: null
certificate:
type: gcp-cm
name: projects/my-project/locations/europe-west4/certificates/my-certificate
```

</div>
Expand All @@ -131,7 +133,7 @@ Setting `load_balancer: { type: alb }` provisions an Application Load Balancer (
An ALB gateway on `gcp` requires:

- The `gcp` backend.
- `certificate: null`.
- Either `certificate: { type: gcp-cm, ... }` or `certificate: null`.
- A VPC with a proxy-only subnet (`purpose: REGIONAL_MANAGED_PROXY`) in the target region — required by GCP for all Envoy-based regional load balancers. See [Proxy-only subnets](https://cloud.google.com/load-balancing/docs/proxy-only-subnets) for how to create one.

The provisioned load balancer provides a hostname (or IP address) you can add to your DNS records. Replica hostnames do not need to be added to DNS.
Expand Down Expand Up @@ -162,6 +164,8 @@ If you disable [public IP](#public-ip) (e.g. to make the gateway private) or if
* `lets-encrypt` (default) — Automatic certificates via [Let's Encrypt](https://letsencrypt.org/). Requires a [public IP](#public-ip).
* `acm` — Certificates managed by [AWS Certificate Manager](https://aws.amazon.com/certificate-manager/). AWS-only. TLS is terminated at the load balancer, not at the gateway, and HTTP requests are redirected to HTTPS by the ALB.
Implies `load_balancer: { type: alb }`.
* `gcp-cm` — Certificates managed by [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs/overview). GCP-only. TLS is terminated at the load balancer, not at the gateway, and the load balancer only serves HTTPS on port 443.
Requires `load_balancer: { type: alb }`. The certificate must be a regional certificate created in the same region as the gateway, referenced by its full resource name.
* `null` — No certificate. Services will use HTTP.

### Public IP
Expand Down Expand Up @@ -244,7 +248,7 @@ $ dstack gateway list
!!! warning "Experimental"
Replicated gateways are an experimental feature and currently have limitations:

- HTTPS is only supported for AWS gateways with the `acm` [certificate type](#certificate). For other gateways, use an external load balancer for TLS termination.
- HTTPS is only supported for AWS gateways with the `acm` [certificate type](#certificate) and GCP gateways with the `gcp-cm` [certificate type](#certificate). For other gateways, use an external load balancer for TLS termination.
- All replicas are bound to the same backend and region.
- At most 3 replicas are allowed per gateway.

Expand Down
8 changes: 8 additions & 0 deletions mkdocs/docs/reference/dstack.yml/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ Set to `null` to disable certificates (e.g. for [private gateways](../../concept
type:
required: true

=== "GCP Certificate Manager"

#SCHEMA# dstack._internal.core.models.gateways.GCPCertificateManagerGatewayCertificate
overrides:
show_root_heading: false
type:
required: true

### `load_balancer`

=== "ALB"
Expand Down
110 changes: 81 additions & 29 deletions src/dstack/_internal/core/backends/gcp/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ class GCPGatewayBackendData(CoreModel):
health_check_name: str
backend_service_name: str
url_map_name: str
target_http_proxy_name: str
target_http_proxy_name: Optional[str] = None
target_https_proxy_name: Optional[str] = None
forwarding_rule_name: str


Expand Down Expand Up @@ -158,6 +159,9 @@ def __init__(self, config: GCPConfig):
credentials=self.credentials
)
self.region_url_maps_client = compute_v1.RegionUrlMapsClient(credentials=self.credentials)
self.region_target_https_proxies_client = compute_v1.RegionTargetHttpsProxiesClient(
credentials=self.credentials
)
self.region_target_http_proxies_client = compute_v1.RegionTargetHttpProxiesClient(
credentials=self.credentials
)
Expand Down Expand Up @@ -691,7 +695,7 @@ def create_gateway_load_balancer(
self,
configuration: GatewayLoadBalancerConfiguration,
) -> GatewayLoadBalancerData:
assert configuration.certificate is None
assert configuration.certificate is None or configuration.certificate.type == "gcp-cm"

zone = self._get_gateway_zone(configuration.region)

Expand Down Expand Up @@ -732,7 +736,7 @@ def create_gateway_load_balancer(
health_check_name = f"{name}-hc"
backend_service_name = f"{name}-bs"
url_map_name = f"{name}-um"
target_http_proxy_name = f"{name}-proxy"
target_proxy_name = f"{name}-proxy"
forwarding_rule_name = f"{name}-fr"

instance_group_resource_name = (
Expand All @@ -750,9 +754,12 @@ def create_gateway_load_balancer(
f"projects/{self.config.project_id}/regions/{configuration.region}"
f"/urlMaps/{url_map_name}"
)
target_http_proxy_resource_name = (
target_proxy_kind = (
"targetHttpProxies" if configuration.certificate is None else "targetHttpsProxies"
)
target_proxy_resource_name = (
f"projects/{self.config.project_id}/regions/{configuration.region}"
f"/targetHttpProxies/{target_http_proxy_name}"
f"/{target_proxy_kind}/{target_proxy_name}"
)

logger.debug("Creating instance group for gateway %s...", configuration.gateway_name)
Expand Down Expand Up @@ -817,25 +824,48 @@ def create_gateway_load_balancer(
gcp_resources.wait_for_extended_operation(operation, "URL map creation")
logger.debug("Created URL map for gateway %s.", configuration.gateway_name)

logger.debug("Creating target HTTP proxy for gateway %s...", configuration.gateway_name)
target_http_proxy = compute_v1.TargetHttpProxy()
target_http_proxy.name = target_http_proxy_name
target_http_proxy.url_map = url_map_resource_name
operation = self.region_target_http_proxies_client.insert(
project=self.config.project_id,
region=configuration.region,
target_http_proxy_resource=target_http_proxy,
)
gcp_resources.wait_for_extended_operation(operation, "target HTTP proxy creation")
logger.debug("Created target HTTP proxy for gateway %s.", configuration.gateway_name)
if configuration.certificate is None:
logger.debug(
"Creating target HTTP proxy for gateway %s...", configuration.gateway_name
)
target_http_proxy = compute_v1.TargetHttpProxy()
target_http_proxy.name = target_proxy_name
target_http_proxy.url_map = url_map_resource_name
operation = self.region_target_http_proxies_client.insert(
project=self.config.project_id,
region=configuration.region,
target_http_proxy_resource=target_http_proxy,
)
gcp_resources.wait_for_extended_operation(operation, "target HTTP proxy creation")
logger.debug("Created target HTTP proxy for gateway %s.", configuration.gateway_name)
else:
logger.debug(
"Creating target HTTPS proxy for gateway %s...", configuration.gateway_name
)
target_https_proxy = compute_v1.TargetHttpsProxy()
target_https_proxy.name = target_proxy_name
target_https_proxy.url_map = url_map_resource_name
target_https_proxy.ssl_certificates = [
gcp_resources.get_certificate_manager_certificate_url(
configuration.certificate.name
)
]
operation = self.region_target_https_proxies_client.insert(
project=self.config.project_id,
region=configuration.region,
target_https_proxy_resource=target_https_proxy,
)
gcp_resources.wait_for_extended_operation(operation, "target HTTPS proxy creation")
logger.debug("Created target HTTPS proxy for gateway %s.", configuration.gateway_name)
# TODO: HTTP->HTTPS redirect?

logger.debug("Creating forwarding rule for gateway %s...", configuration.gateway_name)
forwarding_rule = compute_v1.ForwardingRule()
forwarding_rule.name = forwarding_rule_name
forwarding_rule.load_balancing_scheme = load_balancing_scheme
forwarding_rule.I_p_protocol = compute_v1.ForwardingRule.IPProtocolEnum.TCP.name
forwarding_rule.port_range = "80"
forwarding_rule.target = target_http_proxy_resource_name
forwarding_rule.port_range = "80" if configuration.certificate is None else "443"
forwarding_rule.target = target_proxy_resource_name
forwarding_rule.network = self.config.vpc_resource_name
if subnetwork is not None:
forwarding_rule.subnetwork = subnetwork
Expand All @@ -860,7 +890,12 @@ def create_gateway_load_balancer(
health_check_name=health_check_name,
backend_service_name=backend_service_name,
url_map_name=url_map_name,
target_http_proxy_name=target_http_proxy_name,
target_http_proxy_name=(
target_proxy_name if configuration.certificate is None else None
),
target_https_proxy_name=(
target_proxy_name if configuration.certificate is not None else None
),
forwarding_rule_name=forwarding_rule_name,
).model_dump_json(),
)
Expand Down Expand Up @@ -888,7 +923,7 @@ def terminate_gateway_load_balancer(
logger.debug(
"Deleting load balancer resources for gateway %s...", configuration.gateway_name
)
for delete_call, verbose_name in [
delete_calls = [
(
lambda: self.forwarding_rules_client.delete(
project=self.config.project_id,
Expand All @@ -897,14 +932,30 @@ def terminate_gateway_load_balancer(
),
"forwarding rule deletion",
),
(
lambda: self.region_target_http_proxies_client.delete(
project=self.config.project_id,
region=configuration.region,
target_http_proxy=backend_data_parsed.target_http_proxy_name,
),
"target HTTP proxy deletion",
),
]
if backend_data_parsed.target_http_proxy_name is not None:
delete_calls.append(
(
lambda: self.region_target_http_proxies_client.delete(
project=self.config.project_id,
region=configuration.region,
target_http_proxy=backend_data_parsed.target_http_proxy_name,
),
"target HTTP proxy deletion",
)
)
if backend_data_parsed.target_https_proxy_name is not None:
delete_calls.append(
(
lambda: self.region_target_https_proxies_client.delete(
project=self.config.project_id,
region=configuration.region,
target_https_proxy=backend_data_parsed.target_https_proxy_name,
),
"target HTTPS proxy deletion",
)
)
delete_calls += [
(
lambda: self.region_url_maps_client.delete(
project=self.config.project_id,
Expand Down Expand Up @@ -937,7 +988,8 @@ def terminate_gateway_load_balancer(
),
"instance group deletion",
),
]:
]
for delete_call, verbose_name in delete_calls:
try:
operation = delete_call()
gcp_resources.wait_for_extended_operation(operation, verbose_name)
Expand Down
8 changes: 8 additions & 0 deletions src/dstack/_internal/core/backends/gcp/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

logger = get_logger(__name__)

CERTIFICATE_MANAGER_API_NAMESPACE = "//certificatemanager.googleapis.com"

DSTACK_INSTANCE_TAG = "dstack-runner-instance"
DSTACK_GATEWAY_TAG = "dstack-gateway-instance"

Expand Down Expand Up @@ -691,3 +693,9 @@ def instance_type_supports_persistent_disk(instance_type_name: str) -> bool:
"g4-",
]
)


def get_certificate_manager_certificate_url(certificate_name: str) -> str:
if certificate_name.startswith(("//", "https://")):
return certificate_name
return f"{CERTIFICATE_MANAGER_API_NAMESPACE}/{certificate_name.lstrip('/')}"
27 changes: 26 additions & 1 deletion src/dstack/_internal/core/models/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,34 @@ class ACMGatewayCertificate(CoreModel):
]


class GCPCertificateManagerGatewayCertificate(CoreModel):
type: Annotated[
Literal["gcp-cm"],
Field(
description=(
"Certificates by Google Cloud Certificate Manager."
" Requires `load_balancer: { type: alb }`"
)
),
] = "gcp-cm"
name: Annotated[
str,
Field(
description=(
"The full resource name of the Certificate Manager certificate for the domain,"
" e.g. `projects/my-project/locations/europe-west9/certificates/my-certificate`"
)
),
]


# TODO: Allow setting up custom ACME certificate (e.g. ZeroSSL) via GatewayConfiguration

AnyGatewayCertificate = Union[LetsEncryptGatewayCertificate, ACMGatewayCertificate]
AnyGatewayCertificate = Union[
LetsEncryptGatewayCertificate,
ACMGatewayCertificate,
GCPCertificateManagerGatewayCertificate,
]


class GatewayCertificate(RootModel[Annotated[AnyGatewayCertificate, Field(discriminator="type")]]):
Expand Down
12 changes: 12 additions & 0 deletions src/dstack/_internal/core/services/gateways.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from dstack._internal.core.models.gateways import (
ALBGatewayLoadBalancer,
AnyGatewayCertificate,
AnyGatewayLoadBalancer,
GatewayConfiguration,
)
from dstack._internal.core.services.diff import ModelDiff, diff_models

LOAD_BALANCER_TERMINATED_CERTIFICATE_TYPES = ("acm", "gcp-cm")


def diff_gateway_configurations(old: GatewayConfiguration, new: GatewayConfiguration) -> ModelDiff:
return diff_models(
Expand All @@ -15,11 +18,20 @@ def diff_gateway_configurations(old: GatewayConfiguration, new: GatewayConfigura
)


def is_tls_terminated_at_load_balancer(
certificate: AnyGatewayCertificate | None,
) -> bool:
return (
certificate is not None and certificate.type in LOAD_BALANCER_TERMINATED_CERTIFICATE_TYPES
)


def get_effective_load_balancer(
configuration: GatewayConfiguration,
) -> AnyGatewayLoadBalancer | None:
if configuration.load_balancer is not None:
return configuration.load_balancer
# `acm` implies an ALB for backward compatibility
if configuration.certificate is not None and configuration.certificate.type == "acm":
return ALBGatewayLoadBalancer()
return None
20 changes: 18 additions & 2 deletions src/dstack/_internal/server/services/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,10 +1255,13 @@ def _validate_gateway_configuration(configuration: GatewayConfiguration):
" or `certificate: { type: acm }`"
)
elif configuration.backend == BackendType.GCP:
if configuration.certificate is not None:
if (
configuration.certificate is not None
and configuration.certificate.type != "gcp-cm"
):
raise ServerClientError(
"`load_balancer: { type: alb }` for the `gcp` backend can only be used"
" with `certificate: null`"
" with `certificate: null` or `certificate: { type: gcp-cm }`"
)
else:
raise ServerClientError(
Expand All @@ -1272,6 +1275,15 @@ def _validate_gateway_configuration(configuration: GatewayConfiguration):
)
if configuration.certificate.type == "acm" and configuration.backend != BackendType.AWS:
raise ServerClientError("acm certificate type is supported for aws backend only")
if configuration.certificate.type == "gcp-cm":
if configuration.backend != BackendType.GCP:
raise ServerClientError(
"gcp-cm certificate type is supported for gcp backend only"
)
if configuration.load_balancer is None or configuration.load_balancer.type != "alb":
raise ServerClientError(
"`certificate: { type: gcp-cm }` requires `load_balancer: { type: alb }`"
)
if configuration.certificate.type == "lets-encrypt" and replicas > 1:
err = (
"The `lets-encrypt` certificate type is not supported for gateways with `replicas`"
Expand All @@ -1281,4 +1293,8 @@ def _validate_gateway_configuration(configuration: GatewayConfiguration):
)
if configuration.backend == BackendType.AWS:
err += " or `certificate: { type: acm, arn: <arn> }` (AWS ACM)"
elif configuration.backend == BackendType.GCP:
err += (
" or `certificate: { type: gcp-cm, name: <name> }` (GCP Certificate Manager)"
)
raise ServerClientError(err)
Loading
Loading