deploy: Deploy the registry to GCP (#255)
Adds the Pulumi code to: - Deploy the registry (and associated services e.g. mongodb) to Google Cloud Platform (GCP), on top of Google Kubernetes Engine (GKE) - Sets up proper environments and secrets management - Uses the real container image, now that it's published in #225. At the moment attached to latest, we might want to pin the version later (or perhaps always use `latest` in staging, and pin prod) - Uses real domains (`staging.registry.modelcontextprotocol.io`) rather than examples (``) ## Motivation and Context Setting up infrastructure to deploy it. I set something up in Azure in #227, although not super robust (e.g. no service accounts etc.). Think we will use GCP as: - the maintainers have experience with GCP, but none with Azure - costs are quite low, and Anthropic is happy to cover them in the short term - means we only have to maintain one login system (just Google Cloud Identity), not two (Google Workspace + Azure) ## How Has This Been Tested? Deployed this to a staging and production cluster. Try it yourself at: ```bash curl -H "Host: staging.registry.modelcontextprotocol.io" -k https://35.222.36.75/v0/ping ``` (will be sorting out domains very soon) ## Breaking Changes NA - just adds support for GCP deployment ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> Expected follow-ups: - GitHub Action setup to deploy things to the cluster from GitHub, to avoid gatekeeping to just the people with the secrets.
This commit is contained in:
parent
667e2840b8
commit
fe52766676
4 changed files with 395 additions and 144 deletions
|
|
@ -1,8 +1,10 @@
|
|||
package k8s
|
||||
|
||||
import (
|
||||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
|
||||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes"
|
||||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
|
||||
v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
|
||||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
|
||||
metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
||||
|
||||
|
|
@ -41,5 +43,36 @@ func SetupCertManager(ctx *pulumi.Context, cluster *providers.ProviderInfo) erro
|
|||
return err
|
||||
}
|
||||
|
||||
_, err = apiextensions.NewCustomResource(ctx, "letsencrypt-prod", &apiextensions.CustomResourceArgs{
|
||||
ApiVersion: pulumi.String("cert-manager.io/v1"),
|
||||
Kind: pulumi.String("ClusterIssuer"),
|
||||
Metadata: &metav1.ObjectMetaArgs{
|
||||
Name: pulumi.String("letsencrypt-prod"),
|
||||
},
|
||||
OtherFields: kubernetes.UntypedArgs{
|
||||
"spec": pulumi.Map{
|
||||
"acme": pulumi.Map{
|
||||
"server": pulumi.String("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
"email": pulumi.String("admin@modelcontextprotocol.io"),
|
||||
"privateKeySecretRef": pulumi.Map{
|
||||
"name": pulumi.String("letsencrypt-prod-key"),
|
||||
},
|
||||
"solvers": pulumi.Array{
|
||||
pulumi.Map{
|
||||
"http01": pulumi.Map{
|
||||
"ingress": pulumi.Map{
|
||||
"ingressClassName": pulumi.String("nginx"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, pulumi.Provider(cluster.Provider))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
|
|||
ingressType = "NodePort"
|
||||
}
|
||||
|
||||
nginxIngress, err := helm.NewChart(ctx, "nginx-ingress", helm.ChartArgs{
|
||||
nginxIngress, err := helm.NewChart(ctx, "ingress-nginx", helm.ChartArgs{
|
||||
Chart: pulumi.String("ingress-nginx"),
|
||||
Version: pulumi.String("4.13.0"),
|
||||
FetchArgs: helm.FetchArgs{
|
||||
|
|
@ -45,8 +45,8 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
|
|||
"controller": pulumi.Map{
|
||||
"service": pulumi.Map{
|
||||
"type": pulumi.String(ingressType),
|
||||
// Add Azure Load Balancer health probe annotation as otherwise it defaults to / which fails
|
||||
"annotations": pulumi.Map{
|
||||
// Add Azure Load Balancer health probe annotation as otherwise it defaults to / which fails
|
||||
"service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path": pulumi.String("/healthz"),
|
||||
},
|
||||
},
|
||||
|
|
@ -75,8 +75,8 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
|
|||
// Look up the service after the chart is ready
|
||||
svc, err := v1.GetService(
|
||||
ctx,
|
||||
"nginx-ingress-controller-lookup",
|
||||
pulumi.ID("ingress-nginx/nginx-ingress-ingress-nginx-controller"),
|
||||
"ingress-nginx-controller-lookup",
|
||||
pulumi.ID("ingress-nginx/ingress-nginx-controller"),
|
||||
&v1.ServiceState{},
|
||||
pulumi.Provider(cluster.Provider),
|
||||
pulumi.DependsOn([]pulumi.Resource{nginxIngress}),
|
||||
|
|
|
|||
|
|
@ -62,43 +62,25 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
Spec: &corev1.PodSpecArgs{
|
||||
Containers: corev1.ContainerArray{
|
||||
&corev1.ContainerArgs{
|
||||
Name: pulumi.String("mcp-registry"),
|
||||
// TODO: Replace with actual MCP registry image once on GHCR
|
||||
Image: pulumi.String("nginx:alpine"),
|
||||
Name: pulumi.String("mcp-registry"),
|
||||
Image: pulumi.String("ghcr.io/modelcontextprotocol/registry:latest"),
|
||||
Ports: corev1.ContainerPortArray{
|
||||
&corev1.ContainerPortArgs{
|
||||
// TODO: Change to port 8080 once using registry image
|
||||
ContainerPort: pulumi.Int(80),
|
||||
ContainerPort: pulumi.Int(8080),
|
||||
Name: pulumi.String("http"),
|
||||
},
|
||||
},
|
||||
Env: corev1.EnvVarArray{
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("DATABASE_URL"),
|
||||
Name: pulumi.String("MCP_REGISTRY_DATABASE_URL"),
|
||||
Value: pulumi.String("mongodb://mongodb.default.svc.cluster.local:27017"),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("PORT"),
|
||||
Value: pulumi.String("8080"),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("NODE_ENV"),
|
||||
Value: pulumi.String("production"),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("LOG_LEVEL"),
|
||||
Value: pulumi.String("info"),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("CORS_ORIGINS"),
|
||||
Value: pulumi.String("*"),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("GITHUB_CLIENT_ID"),
|
||||
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_ID"),
|
||||
Value: pulumi.String(githubClientId),
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("GITHUB_CLIENT_SECRET"),
|
||||
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_SECRET"),
|
||||
ValueFrom: &corev1.EnvVarSourceArgs{
|
||||
SecretKeyRef: &corev1.SecretKeySelectorArgs{
|
||||
Name: secret.Metadata.Name(),
|
||||
|
|
@ -107,23 +89,22 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
},
|
||||
},
|
||||
},
|
||||
// TODO: uncomment when using registry image
|
||||
// LivenessProbe: &corev1.ProbeArgs{
|
||||
// HttpGet: &corev1.HTTPGetActionArgs{
|
||||
// Path: pulumi.String("/v0/health"),
|
||||
// Port: pulumi.Int(8080),
|
||||
// },
|
||||
// InitialDelaySeconds: pulumi.Int(30),
|
||||
// TimeoutSeconds: pulumi.Int(5),
|
||||
// },
|
||||
// ReadinessProbe: &corev1.ProbeArgs{
|
||||
// HttpGet: &corev1.HTTPGetActionArgs{
|
||||
// Path: pulumi.String("/v0/health"),
|
||||
// Port: pulumi.Int(8080),
|
||||
// },
|
||||
// InitialDelaySeconds: pulumi.Int(5),
|
||||
// TimeoutSeconds: pulumi.Int(3),
|
||||
// },
|
||||
LivenessProbe: &corev1.ProbeArgs{
|
||||
HttpGet: &corev1.HTTPGetActionArgs{
|
||||
Path: pulumi.String("/v0/health"),
|
||||
Port: pulumi.Int(8080),
|
||||
},
|
||||
InitialDelaySeconds: pulumi.Int(30),
|
||||
TimeoutSeconds: pulumi.Int(5),
|
||||
},
|
||||
ReadinessProbe: &corev1.ProbeArgs{
|
||||
HttpGet: &corev1.HTTPGetActionArgs{
|
||||
Path: pulumi.String("/v0/health"),
|
||||
Port: pulumi.Int(8080),
|
||||
},
|
||||
InitialDelaySeconds: pulumi.Int(5),
|
||||
TimeoutSeconds: pulumi.Int(3),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -150,9 +131,8 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
},
|
||||
Ports: corev1.ServicePortArray{
|
||||
&corev1.ServicePortArgs{
|
||||
Port: pulumi.Int(80),
|
||||
// TODO: Change to port 8080 once using registry image
|
||||
TargetPort: pulumi.Int(80),
|
||||
Port: pulumi.Int(80),
|
||||
TargetPort: pulumi.Int(8080),
|
||||
Name: pulumi.String("http"),
|
||||
},
|
||||
},
|
||||
|
|
@ -164,6 +144,7 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
}
|
||||
|
||||
// Create Ingress
|
||||
domain := "%s.registry.modelcontextprotocol.io"
|
||||
ingress, err := networkingv1.NewIngress(ctx, "mcp-registry", &networkingv1.IngressArgs{
|
||||
Metadata: &metav1.ObjectMetaArgs{
|
||||
Name: pulumi.String("mcp-registry"),
|
||||
|
|
@ -173,23 +154,21 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
"environment": pulumi.String(environment),
|
||||
},
|
||||
Annotations: pulumi.StringMap{
|
||||
"kubernetes.io/ingress.class": pulumi.String("nginx"),
|
||||
"cert-manager.io/cluster-issuer": pulumi.String("letsencrypt-prod"),
|
||||
"nginx.ingress.kubernetes.io/rewrite-target": pulumi.String("/"),
|
||||
"kubernetes.io/ingress.class": pulumi.String("nginx"),
|
||||
},
|
||||
},
|
||||
Spec: &networkingv1.IngressSpecArgs{
|
||||
Tls: networkingv1.IngressTLSArray{
|
||||
&networkingv1.IngressTLSArgs{
|
||||
Hosts: pulumi.StringArray{
|
||||
pulumi.Sprintf("mcp-registry-%s.example.com", environment),
|
||||
pulumi.Sprintf(domain, environment),
|
||||
},
|
||||
SecretName: pulumi.Sprintf("mcp-registry-%s-tls", environment),
|
||||
},
|
||||
},
|
||||
Rules: networkingv1.IngressRuleArray{
|
||||
&networkingv1.IngressRuleArgs{
|
||||
Host: pulumi.Sprintf("mcp-registry-%s.example.com", environment),
|
||||
Host: pulumi.Sprintf(domain, environment),
|
||||
Http: &networkingv1.HTTPIngressRuleValueArgs{
|
||||
Paths: networkingv1.HTTPIngressPathArray{
|
||||
&networkingv1.HTTPIngressPathArgs{
|
||||
|
|
|
|||
182
deploy/pkg/providers/gcp/provider.go
Normal file
182
deploy/pkg/providers/gcp/provider.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package gcp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp"
|
||||
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container"
|
||||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
|
||||
)
|
||||
|
||||
// Provider implements the ClusterProvider interface for Google Kubernetes Engine
|
||||
type Provider struct{}
|
||||
|
||||
// CreateCluster creates a Google Kubernetes Engine cluster
|
||||
func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*providers.ProviderInfo, error) {
|
||||
// Get configuration
|
||||
gcpConf := config.New(ctx, "gcp")
|
||||
|
||||
// Get project ID from config or use default
|
||||
projectID := gcpConf.Get("project")
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("GCP project ID not configured. Set gcp:project")
|
||||
}
|
||||
|
||||
// Get region from config or use default
|
||||
region := gcpConf.Get("region")
|
||||
if region == "" {
|
||||
region = "us-central1"
|
||||
}
|
||||
|
||||
// Get credentials from config (base64 encoded service account JSON)
|
||||
credentials := gcpConf.Get("credentials")
|
||||
if credentials != "" {
|
||||
// Decode the base64 credentials
|
||||
decodedCreds, err := base64.StdEncoding.DecodeString(credentials)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode GCP credentials: %w", err)
|
||||
}
|
||||
credentials = string(decodedCreds)
|
||||
}
|
||||
|
||||
// Create a GCP provider with explicit credentials if provided
|
||||
var gcpProvider *gcp.Provider
|
||||
var err error
|
||||
if credentials != "" {
|
||||
gcpProvider, err = gcp.NewProvider(ctx, "gcp-explicit", &gcp.ProviderArgs{
|
||||
Project: pulumi.String(projectID),
|
||||
Region: pulumi.String(region),
|
||||
Credentials: pulumi.String(credentials),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCP provider: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create GKE cluster
|
||||
clusterName := fmt.Sprintf("mcp-registry-%s", environment)
|
||||
|
||||
// Use a specific zone instead of region for zonal cluster
|
||||
zone := fmt.Sprintf("%s-b", region)
|
||||
|
||||
// Configure the GKE cluster
|
||||
clusterArgs := &container.ClusterArgs{
|
||||
Name: pulumi.String(clusterName),
|
||||
Location: pulumi.String(zone),
|
||||
Project: pulumi.String(projectID),
|
||||
Description: pulumi.String(fmt.Sprintf("MCP Registry %s GKE Cluster", environment)),
|
||||
|
||||
// Initial node count (will be managed by node pool)
|
||||
InitialNodeCount: pulumi.Int(1),
|
||||
|
||||
// Remove default node pool after cluster creation
|
||||
RemoveDefaultNodePool: pulumi.Bool(true),
|
||||
|
||||
AddonsConfig: &container.ClusterAddonsConfigArgs{
|
||||
// Disable as we use ingress-nginx
|
||||
HttpLoadBalancing: &container.ClusterAddonsConfigHttpLoadBalancingArgs{
|
||||
Disabled: pulumi.Bool(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add provider if we have explicit credentials
|
||||
clusterOpts := []pulumi.ResourceOption{}
|
||||
if gcpProvider != nil {
|
||||
clusterOpts = append(clusterOpts, pulumi.Provider(gcpProvider))
|
||||
}
|
||||
|
||||
cluster, err := container.NewCluster(ctx, clusterName, clusterArgs, clusterOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GKE cluster: %w", err)
|
||||
}
|
||||
|
||||
// Create a managed node pool for the cluster
|
||||
nodePoolName := fmt.Sprintf("%s-nodepool", clusterName)
|
||||
nodePoolArgs := &container.NodePoolArgs{
|
||||
Cluster: cluster.Name,
|
||||
Location: pulumi.String(zone),
|
||||
Project: pulumi.String(projectID),
|
||||
|
||||
// Node pool configuration
|
||||
NodeCount: pulumi.Int(2),
|
||||
NodeConfig: &container.NodePoolNodeConfigArgs{
|
||||
MachineType: pulumi.String("e2-small"),
|
||||
DiskSizeGb: pulumi.Int(20),
|
||||
DiskType: pulumi.String("pd-standard"),
|
||||
},
|
||||
|
||||
// Node management configuration
|
||||
Management: &container.NodePoolManagementArgs{
|
||||
AutoRepair: pulumi.Bool(true),
|
||||
AutoUpgrade: pulumi.Bool(true),
|
||||
},
|
||||
}
|
||||
|
||||
// Add provider if we have explicit credentials
|
||||
nodePoolOpts := []pulumi.ResourceOption{}
|
||||
if gcpProvider != nil {
|
||||
nodePoolOpts = append(nodePoolOpts, pulumi.Provider(gcpProvider))
|
||||
}
|
||||
|
||||
nodePool, err := container.NewNodePool(ctx, nodePoolName, nodePoolArgs, nodePoolOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create node pool: %w", err)
|
||||
}
|
||||
|
||||
// Create Kubernetes provider using the cluster directly
|
||||
k8sProvider, err := kubernetes.NewProvider(ctx, "k8s-provider", &kubernetes.ProviderArgs{
|
||||
Kubeconfig: pulumi.All(cluster.Endpoint, cluster.MasterAuth).ApplyT(func(args []any) (string, error) {
|
||||
endpoint := args[0].(string)
|
||||
masterAuth := args[1].(container.ClusterMasterAuth)
|
||||
context := fmt.Sprintf("%s_%s_%s", projectID, zone, clusterName)
|
||||
|
||||
// Extract CA certificate
|
||||
caCert := ""
|
||||
if masterAuth.ClusterCaCertificate != nil {
|
||||
caCert = *masterAuth.ClusterCaCertificate
|
||||
}
|
||||
|
||||
// Create kubeconfig using gke-gcloud-auth-plugin
|
||||
kubeconfigYAML := fmt.Sprintf(`apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: %s
|
||||
server: https://%s
|
||||
name: %s
|
||||
contexts:
|
||||
- context:
|
||||
cluster: %s
|
||||
user: %s
|
||||
name: %s
|
||||
current-context: %s
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: %s
|
||||
user:
|
||||
exec:
|
||||
apiVersion: client.authentication.k8s.io/v1beta1
|
||||
command: gke-gcloud-auth-plugin
|
||||
installHint: Install gke-gcloud-auth-plugin for use with kubectl by following
|
||||
https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke
|
||||
provideClusterInfo: true
|
||||
`, caCert, endpoint, context, context, context, context, context, context)
|
||||
|
||||
return kubeconfigYAML, nil
|
||||
}).(pulumi.StringOutput),
|
||||
}, pulumi.DependsOn([]pulumi.Resource{cluster, nodePool}))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Kubernetes provider: %w", err)
|
||||
}
|
||||
|
||||
return &providers.ProviderInfo{
|
||||
Name: nodePool.Cluster,
|
||||
Provider: k8sProvider,
|
||||
}, nil
|
||||
}
|
||||
Loading…
Reference in a new issue