diff --git a/deployment/config/public-graphql-api-gateway/gateway.config.ts b/deployment/config/public-graphql-api/gateway.config.ts similarity index 100% rename from deployment/config/public-graphql-api-gateway/gateway.config.ts rename to deployment/config/public-graphql-api/gateway.config.ts diff --git a/deployment/config/public-graphql-api/router.config.yaml b/deployment/config/public-graphql-api/router.config.yaml new file mode 100644 index 00000000000..42d00d7bc93 --- /dev/null +++ b/deployment/config/public-graphql-api/router.config.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://github.com/graphql-hive/router/releases/download/hive-router%2Fv0.0.39/router-config.schema.json +override_subgraph_urls: + graphql: + url: + expression: env("INTERNAL_GRAPHQL_URL") +telemetry: + hive: + token: + expression: env("HIVE_ACCESS_TOKEN") + target: + expression: env("HIVE_TARGET") + tracing: + enabled: true + endpoint: env("HIVE_TRACE_ENDPOINT") + tracing: + exporters: + - kind: otlp + enabled: true + protocol: http + endpoint: + expression: env("OPENTELEMETRY_COLLECTOR_ENDPOINT") diff --git a/deployment/index.ts b/deployment/index.ts index 90128423c51..ba11180958a 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -19,6 +19,7 @@ import { deploySchemaPolicy } from './services/policy'; import { deployPostgres } from './services/postgres'; import { deployProxy } from './services/proxy'; import { deployPublicGraphQLAPIGateway } from './services/public-graphql-api-gateway'; +import { deployPublicGraphQLAPIRouter } from './services/public-graphql-api-router'; import { deployRedis } from './services/redis'; import { deployS3, deployS3AuditLog, deployS3Mirror } from './services/s3'; import { deploySchema } from './services/schema'; @@ -311,6 +312,14 @@ const publicGraphQLAPIGateway = deployPublicGraphQLAPIGateway({ otelCollector, }); +const publicGraphQLAPIRouter = deployPublicGraphQLAPIRouter({ + environment, + graphql, + docker, + observability, + otelCollector, +}); + const proxy = deployProxy({ observability, app, @@ -318,6 +327,7 @@ const proxy = deployProxy({ usage, environment, publicGraphQLAPIGateway, + publicGraphQLAPIRouter, otelCollector, }); diff --git a/deployment/services/proxy.ts b/deployment/services/proxy.ts index 07037456b7b..7948c5cdbf4 100644 --- a/deployment/services/proxy.ts +++ b/deployment/services/proxy.ts @@ -7,6 +7,7 @@ import { GraphQL } from './graphql'; import { Observability } from './observability'; import { OTELCollector } from './otel-collector'; import { type PublicGraphQLAPIGateway } from './public-graphql-api-gateway'; +import { type PublicGraphQLAPIRouter } from './public-graphql-api-router'; import { Usage } from './usage'; export function deployProxy({ @@ -16,6 +17,7 @@ export function deployProxy({ environment, observability, publicGraphQLAPIGateway, + publicGraphQLAPIRouter, otelCollector, }: { observability: Observability; @@ -24,6 +26,7 @@ export function deployProxy({ app: App; usage: Usage; publicGraphQLAPIGateway: PublicGraphQLAPIGateway; + publicGraphQLAPIRouter: PublicGraphQLAPIRouter; otelCollector: OTELCollector; }) { const { tlsIssueName } = new CertManager().deployCertManagerAndIssuer(); @@ -108,14 +111,6 @@ export function deployProxy({ }, ]) .registerService({ record: environment.apiDns }, [ - { - name: 'public-graphql-api', - path: '/graphql', - customRewrite: '/graphql', - service: publicGraphQLAPIGateway.service, - requestTimeout: '60s', - retriable: true, - }, { name: 'otel-traces', path: '/otel/v1/traces', @@ -124,5 +119,17 @@ export function deployProxy({ requestTimeout: '60s', retriable: true, }, + { + name: 'public-graphql-api', + path: '/graphql', + customRewrite: '/graphql', + // Here we split traffic between the two services: Hive Gateway and Hive Router + service: [ + { upstream: publicGraphQLAPIGateway.service, weight: 50 }, + { upstream: publicGraphQLAPIRouter.service, weight: 50 }, + ], + requestTimeout: '60s', + retriable: true, + }, ]); } diff --git a/deployment/services/public-graphql-api-gateway.ts b/deployment/services/public-graphql-api-gateway.ts index a3c33a3df76..4cd15db42ec 100644 --- a/deployment/services/public-graphql-api-gateway.ts +++ b/deployment/services/public-graphql-api-gateway.ts @@ -17,12 +17,7 @@ import { type OTELCollector } from './otel-collector'; */ const dockerImage = 'ghcr.io/graphql-hive/gateway:2.1.19'; -const gatewayConfigDirectory = path.resolve( - __dirname, - '..', - 'config', - 'public-graphql-api-gateway', -); +const gatewayConfigDirectory = path.resolve(__dirname, '..', 'config', 'public-graphql-api'); // On global scope to fail early in case of a read error const gatewayConfigPath = path.join(gatewayConfigDirectory, 'gateway.config.ts'); diff --git a/deployment/services/public-graphql-api-router.ts b/deployment/services/public-graphql-api-router.ts new file mode 100644 index 00000000000..c7e69081084 --- /dev/null +++ b/deployment/services/public-graphql-api-router.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as kx from '@pulumi/kubernetesx'; +import * as pulumi from '@pulumi/pulumi'; +import { serviceLocalEndpoint } from '../utils/local-endpoint'; +import { ServiceSecret } from '../utils/secrets'; +import { ServiceDeployment } from '../utils/service-deployment'; +import { type Docker } from './docker'; +import { type Environment } from './environment'; +import { type GraphQL } from './graphql'; +import { type Observability } from './observability'; +import { type OTELCollector } from './otel-collector'; + +/** + * Hive Router Docker Image Version + */ +const dockerImage = 'ghcr.io/graphql-hive/router:0.0.39'; +const configDirectory = path.resolve(__dirname, '..', 'config', 'public-graphql-api'); + +// On global scope to fail early in case of a read error +const configPath = path.join(configDirectory, 'router.config.yaml'); +const routerConfigFile = fs.readFileSync(configPath, 'utf-8'); + +export function deployPublicGraphQLAPIRouter(args: { + environment: Environment; + graphql: GraphQL; + docker: Docker; + observability: Observability; + otelCollector: OTELCollector; +}) { + const apiConfig = new pulumi.Config('api'); + + // Note: The persisted documents cdn endpoint can also be used for reading the contract schema + const cdnEndpoint = + apiConfig.requireObject>('env')['HIVE_PERSISTED_DOCUMENTS_CDN_ENDPOINT']; + + if (!cdnEndpoint) { + throw new Error("Missing cdn endpoint variable 'HIVE_PERSISTED_DOCUMENTS_CDN_ENDPOINT'."); + } + + const hiveConfig = new pulumi.Config('hive'); + const hiveConfigSecrets = new ServiceSecret('hive-router-tracing-secret', { + otelTraceAccessToken: hiveConfig.requireSecret('otelTraceAccessToken'), + }); + + const supergraphEndpoint = cdnEndpoint + '/contracts/public'; + + // Note: The persisted documents access key is also valid for reading the supergraph + const publicGraphQLAPISecret = new ServiceSecret('public-graphql-api-router-secret', { + cdnAccessKeyId: apiConfig.requireSecret('hivePersistedDocumentsCdnAccessKeyId'), + }); + + const routerConfigDirAbsolutePath = '/config/'; + const routerConfigFileName = 'router.yaml'; + + const configMap = new kx.ConfigMap('public-graphql-api-router-config', { + data: { + [routerConfigFileName]: routerConfigFile, + }, + }); + + const mountName = 'router-config'; + + return new ServiceDeployment( + 'public-graphql-api-router', + { + imagePullSecret: args.docker.secret, + image: dockerImage, + replicas: args.environment.podsConfig.general.replicas, + availabilityOnEveryNode: true, + env: { + ROUTER_CONFIG_FILE_PATH: `${routerConfigDirAbsolutePath}${routerConfigFileName}`, + INTERNAL_GRAPHQL_URL: serviceLocalEndpoint(args.graphql.service).apply( + value => `${value}/graphql-public`, + ), + HIVE_CDN_ENDPOINT: supergraphEndpoint, + HIVE_TARGET: hiveConfig.require('target'), + OPENTELEMETRY_COLLECTOR_ENDPOINT: args.observability.tracingEndpoint ?? '', + HIVE_TRACE_ENDPOINT: serviceLocalEndpoint(args.otelCollector.service).apply( + value => `${value}/v1/traces`, + ), + }, + port: 4000, + volumes: [ + { + name: mountName, + configMap: { + name: configMap.metadata.name, + }, + }, + ], + volumeMounts: [ + { + mountPath: routerConfigDirAbsolutePath, + name: mountName, + readOnly: true, + }, + ], + readinessProbe: '/readiness', + livenessProbe: '/health', + startupProbe: { + endpoint: '/health', + initialDelaySeconds: 60, + failureThreshold: 10, + periodSeconds: 15, + timeoutSeconds: 15, + }, + }, + [args.graphql.deployment, args.graphql.service], + ) + .withSecret('HIVE_CDN_KEY', publicGraphQLAPISecret, 'cdnAccessKeyId') + .withSecret('HIVE_ACCESS_TOKEN', hiveConfigSecrets, 'otelTraceAccessToken') + .deploy(); +} + +export type PublicGraphQLAPIRouter = ReturnType; diff --git a/deployment/utils/reverse-proxy.ts b/deployment/utils/reverse-proxy.ts index 9859c839fcf..9f401016be4 100644 --- a/deployment/utils/reverse-proxy.ts +++ b/deployment/utils/reverse-proxy.ts @@ -6,6 +6,15 @@ import { helmChart } from './helm'; // prettier-ignore export const CONTOUR_CHART = helmChart('https://raw.githubusercontent.com/bitnami/charts/refs/heads/index/bitnami/', 'contour', '20.0.3'); +type SingleUpstream = k8s.core.v1.Service; +type WeightBasedUpstream = Array<{ upstream: k8s.core.v1.Service; weight: number }>; + +function isWeightBasedUpstream( + upstream: SingleUpstream | WeightBasedUpstream, +): upstream is WeightBasedUpstream { + return Array.isArray(upstream); +} + export class Proxy { private lbService: Output | null = null; @@ -80,7 +89,7 @@ export class Proxy { routes: { name: string; path: string; - service: k8s.core.v1.Service; + service: SingleUpstream | WeightBasedUpstream; requestTimeout?: `${number}s` | 'infinity'; idleTimeout?: `${number}s`; retriable?: boolean; @@ -147,12 +156,18 @@ export class Proxy { prefix: route.path, }, ], - services: [ - { - name: route.service.metadata.name, - port: route.service.spec.ports[0].port, - }, - ], + services: isWeightBasedUpstream(route.service) + ? route.service.map(serviceDef => ({ + name: serviceDef.upstream.metadata.name, + port: serviceDef.upstream.spec.ports[0].port, + weight: serviceDef.weight, + })) + : [ + { + name: route.service.metadata.name, + port: route.service.spec.ports[0].port, + }, + ], // https://projectcontour.io/docs/1.29/config/request-routing/#session-affinity loadBalancerPolicy: { strategy: 'Cookie',