From 0b71c83c6ee16f001487083b88ac657fd4d0ef19 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Mon, 14 Sep 2026 22:49:58 +0200 Subject: [PATCH 1/2] Support Certificate Manager for GCP gateways Allow to attach certificates managed by Google Cloud Certificate Manager to GCP gateways with an ALB. ```yaml type: gateway name: example-gateway backend: gcp region: europe-west4 domain: example.com replicas: 2 load_balancer: type: alb certificate: type: gcp-cm name: projects/my-project/locations/europe-west4/certificates/my-certificate ``` This enables HTTPS for services behind the gateway, with TLS terminated at the load balancer. The certificate must be a regional certificate created in the same region as the gateway. New permissions are required to create such gateways: ``` certificatemanager.certs.use compute.regionTargetHttpsProxies.create compute.regionTargetHttpsProxies.delete compute.regionTargetHttpsProxies.use ``` --- mkdocs/docs/concepts/backends.md | 9 ++ mkdocs/docs/concepts/gateways.md | 10 +- mkdocs/docs/reference/dstack.yml/gateway.md | 8 ++ .../_internal/core/backends/gcp/compute.py | 109 +++++++++++++----- .../_internal/core/backends/gcp/resources.py | 8 ++ src/dstack/_internal/core/models/gateways.py | 27 ++++- .../_internal/core/services/gateways.py | 12 ++ .../server/services/gateways/__init__.py | 20 +++- .../server/services/services/__init__.py | 15 ++- .../_internal/server/routers/test_gateways.py | 61 +++++++++- 10 files changed, 235 insertions(+), 44 deletions(-) diff --git a/mkdocs/docs/concepts/backends.md b/mkdocs/docs/concepts/backends.md index 7c91b2782..110c1b78a 100644 --- a/mkdocs/docs/concepts/backends.md +++ b/mkdocs/docs/concepts/backends.md @@ -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: ``` diff --git a/mkdocs/docs/concepts/gateways.md b/mkdocs/docs/concepts/gateways.md index 268b6bae6..ca4cf3e2c 100644 --- a/mkdocs/docs/concepts/gateways.md +++ b/mkdocs/docs/concepts/gateways.md @@ -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 ``` @@ -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. @@ -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 @@ -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. diff --git a/mkdocs/docs/reference/dstack.yml/gateway.md b/mkdocs/docs/reference/dstack.yml/gateway.md index 65a3409c5..d52fac9e0 100644 --- a/mkdocs/docs/reference/dstack.yml/gateway.md +++ b/mkdocs/docs/reference/dstack.yml/gateway.md @@ -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" diff --git a/src/dstack/_internal/core/backends/gcp/compute.py b/src/dstack/_internal/core/backends/gcp/compute.py index 057cde7fe..7eb9f3184 100644 --- a/src/dstack/_internal/core/backends/gcp/compute.py +++ b/src/dstack/_internal/core/backends/gcp/compute.py @@ -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 @@ -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 ) @@ -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) @@ -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 = ( @@ -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) @@ -817,25 +824,47 @@ 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) 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 @@ -860,7 +889,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(), ) @@ -888,7 +922,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, @@ -897,14 +931,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, @@ -937,7 +987,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) diff --git a/src/dstack/_internal/core/backends/gcp/resources.py b/src/dstack/_internal/core/backends/gcp/resources.py index ed1ace65a..a9f1d2354 100644 --- a/src/dstack/_internal/core/backends/gcp/resources.py +++ b/src/dstack/_internal/core/backends/gcp/resources.py @@ -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" @@ -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('/')}" diff --git a/src/dstack/_internal/core/models/gateways.py b/src/dstack/_internal/core/models/gateways.py index 623d87c07..bff16d3e5 100644 --- a/src/dstack/_internal/core/models/gateways.py +++ b/src/dstack/_internal/core/models/gateways.py @@ -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")]]): diff --git a/src/dstack/_internal/core/services/gateways.py b/src/dstack/_internal/core/services/gateways.py index 3fc244ffa..3ee31ac05 100644 --- a/src/dstack/_internal/core/services/gateways.py +++ b/src/dstack/_internal/core/services/gateways.py @@ -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( @@ -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 diff --git a/src/dstack/_internal/server/services/gateways/__init__.py b/src/dstack/_internal/server/services/gateways/__init__.py index 7b0705fae..6b5f789dc 100644 --- a/src/dstack/_internal/server/services/gateways/__init__.py +++ b/src/dstack/_internal/server/services/gateways/__init__.py @@ -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( @@ -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`" @@ -1281,4 +1293,8 @@ def _validate_gateway_configuration(configuration: GatewayConfiguration): ) if configuration.backend == BackendType.AWS: err += " or `certificate: { type: acm, arn: }` (AWS ACM)" + elif configuration.backend == BackendType.GCP: + err += ( + " or `certificate: { type: gcp-cm, name: }` (GCP Certificate Manager)" + ) raise ServerClientError(err) diff --git a/src/dstack/_internal/server/services/services/__init__.py b/src/dstack/_internal/server/services/services/__init__.py index 48a55714d..91499880b 100644 --- a/src/dstack/_internal/server/services/services/__init__.py +++ b/src/dstack/_internal/server/services/services/__init__.py @@ -17,6 +17,7 @@ from dstack._internal.core.models.gateways import GatewayConfiguration, GatewayStatus from dstack._internal.core.models.runs import RunSpec, ServiceModelSpec, ServiceSpec from dstack._internal.core.models.services import OpenAIChatModel +from dstack._internal.core.services.gateways import is_tls_terminated_at_load_balancer from dstack._internal.server import settings from dstack._internal.server.models import GatewayModel, RunModel from dstack._internal.server.services import events @@ -117,14 +118,12 @@ async def _assign_service_to_gateway( show_service_https = _should_show_service_https(run_spec, gateway_configuration) service_protocol = "https" if show_service_https else "http" - if ( - not show_service_https - and gateway_configuration.certificate is not None - and gateway_configuration.certificate.type == "acm" + if not show_service_https and is_tls_terminated_at_load_balancer( + gateway_configuration.certificate ): # SSL termination is done globally at load balancer so cannot runs only some services via http. raise ServerClientError( - "Cannot run HTTP service on gateway with ACM certificates configured" + "Cannot run HTTP service on gateway with load balancer certificates configured" ) if show_service_https and gateway_configuration.certificate is None: @@ -235,12 +234,12 @@ def should_configure_service_https_on_gateway( if https == "auto": if configuration.certificate is None: return False - if configuration.certificate.type == "acm": + if is_tls_terminated_at_load_balancer(configuration.certificate): return False return True if not https: return False - if configuration.certificate is not None and configuration.certificate.type == "acm": + if is_tls_terminated_at_load_balancer(configuration.certificate): return False return True @@ -261,7 +260,7 @@ def _should_show_service_https(run_spec: RunSpec, configuration: GatewayConfigur def get_gateway_https(configuration: GatewayConfiguration) -> bool: - if configuration.certificate is not None and configuration.certificate.type == "acm": + if is_tls_terminated_at_load_balancer(configuration.certificate): return False if configuration.certificate is not None and configuration.certificate.type == "lets-encrypt": return True diff --git a/src/tests/_internal/server/routers/test_gateways.py b/src/tests/_internal/server/routers/test_gateways.py index 98e614df9..748cc8cfa 100644 --- a/src/tests/_internal/server/routers/test_gateways.py +++ b/src/tests/_internal/server/routers/test_gateways.py @@ -792,9 +792,68 @@ async def test_create_gateway_with_invalid_domain_interpolation( "load_balancer": {"type": "alb"}, }, "`load_balancer: { type: alb }` for the `gcp` backend can only be used with" - " `certificate: null`", + " `certificate: null` or `certificate: { type: gcp-cm }`", id="gcp-load-balancer-with-lets-encrypt-cert", ), + pytest.param( + { + "type": "gateway", + "name": "test", + "backend": "aws", + "region": "us", + "certificate": { + "type": "gcp-cm", + "name": "projects/p/locations/us/certificates/c", + }, + }, + "gcp-cm certificate type is supported for gcp backend only", + id="gcp-cm-cert-with-aws-backend", + ), + pytest.param( + { + "type": "gateway", + "name": "test", + "backend": "gcp", + "region": "us", + "certificate": { + "type": "gcp-cm", + "name": "projects/p/locations/us/certificates/c", + }, + }, + "`certificate: { type: gcp-cm }` requires `load_balancer: { type: alb }`", + id="gcp-cm-cert-without-load-balancer", + ), + pytest.param( + { + "type": "gateway", + "name": "test", + "backend": "aws", + "region": "us", + "certificate": { + "type": "gcp-cm", + "name": "projects/p/locations/us/certificates/c", + }, + "load_balancer": {"type": "alb"}, + }, + "`load_balancer: { type: alb }` can only be used with `certificate: null` or" + " `certificate: { type: acm }`", + id="aws-load-balancer-with-gcp-cm-cert", + ), + pytest.param( + { + "type": "gateway", + "name": "test", + "backend": "gcp", + "region": "us", + "replicas": 2, + }, + "The `lets-encrypt` certificate type is not supported for gateways with `replicas`" + " greater than `1`. To create a replicated gateway, set the `certificate`" + " configuration property to one of the supported values, such as" + " `certificate: null` (no HTTPS)" + " or `certificate: { type: gcp-cm, name: }` (GCP Certificate Manager)", + id="gcp-multi-replica-with-letsencrypt-cert", + ), ], ) async def test_invalid_configuration_rejected( From f5d5f35eb95fb7a723c119a3c1f24df8e656cfe0 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Wed, 16 Sep 2026 08:19:59 +0200 Subject: [PATCH 2/2] Add HTTP->HTTPS redirect TODO --- src/dstack/_internal/core/backends/gcp/compute.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dstack/_internal/core/backends/gcp/compute.py b/src/dstack/_internal/core/backends/gcp/compute.py index 7eb9f3184..d40647604 100644 --- a/src/dstack/_internal/core/backends/gcp/compute.py +++ b/src/dstack/_internal/core/backends/gcp/compute.py @@ -857,6 +857,7 @@ def create_gateway_load_balancer( ) 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()