diff --git a/deploy/README.md b/deploy/README.md index 175934c..12a54c2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -46,6 +46,7 @@ Pre-requisites: gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/container.admin" gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/compute.admin" gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/storage.admin" + gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/storage.hmacKeyAdmin" gcloud iam service-accounts add-iam-policy-binding $(gcloud projects describe mcp-registry-prod --format="value(projectNumber)")-compute@developer.gserviceaccount.com --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/iam.serviceAccountUser" gcloud iam service-accounts keys create sa-key.json --iam-account=pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com ``` @@ -100,6 +101,7 @@ Pre-requisites: ├── go.sum # Go module checksums └── pkg/ # Infrastructure packages ├── k8s/ # Kubernetes deployment components + │ ├── backup.go # Database backup configuration │ ├── cert_manager.go # SSL certificate management │ ├── deploy.go # Deployment orchestration │ ├── ingress.go # Ingress controller setup @@ -123,6 +125,7 @@ Pre-requisites: - Certificate manager for SSL/TLS - Ingress controller for external access - Database for data persistence + - Backup infrastructure for database - MCP Registry application ## Configuration @@ -136,6 +139,48 @@ Pre-requisites: | `gcpProjectId` | GCP Project ID (required when provider=gcp) | No | | `gcpRegion` | GCP Region (default: us-central1) | No | +## Database Backups + +The deployment uses [K8up](https://k8up.io/) (a Kubernetes backup operator) that uses [Restic](https://restic.net/) under the hood. + +When running locally they are stored in a Minio bucket. In staging and production, backups are stored in a GCS bucket. + +### Accessing Backup Files + +#### Local Development (MinIO) + +```bash +# Expose MinIO web console +kubectl port-forward -n minio svc/minio 9000:9000 9001:9001 +``` + +Then open [localhost:9001](http://localhost:9001), login with username `minioadmin` and password `minioadmin`, and navigate to the k8up-backups bucket. + +##### Staging and Production (GCS) + +- [Staging](https://console.cloud.google.com/storage/browser/mcp-registry-staging-backups?project=mcp-registry-staging) +- [Production](https://console.cloud.google.com/storage/browser/mcp-registry-prod-backups?project=mcp-registry-prod) + +#### Decrypting and Restoring Backups + +Backups are encrypted using Restic. To access the backup data: + +1. **Download the backup files from the bucket:** + ```bash + # Local (MinIO) - ensure port-forward is active: kubectl port-forward -n minio svc/minio 9000:9000 9001:9001 + AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \ + aws --endpoint-url http://localhost:9000 s3 sync s3://k8up-backups/ ./backup-files/ + + # GCS (staging/production) + gsutil -m cp -r gs://mcp-registry-{staging|prod}-backups/* ./backup-files/ + ``` +2. **[Install Restic](https://restic.readthedocs.io/en/latest/020_installation.html)** +3. **Restore the backup:** + ```bash + RESTIC_PASSWORD=password restic -r ./backup-files restore latest --target ./restored-files + ``` + PostgreSQL data will be in `./restored-files/data/registry-pg-1/pgdata/` + ## Troubleshooting ### Check Status @@ -154,3 +199,9 @@ kubectl get svc -n ingress-nginx kubectl logs -l app=mcp-registry kubectl logs -l app=postgres ``` + +### Check Backup Status +```bash +kubectl describe schedule.k8up.io +kubectl get backup +``` diff --git a/deploy/go.mod b/deploy/go.mod index 4a1b69a..1e24cb4 100644 --- a/deploy/go.mod +++ b/deploy/go.mod @@ -5,8 +5,6 @@ go 1.23.0 toolchain go1.24.1 require ( - github.com/pulumi/pulumi-azure-native-sdk/containerservice v1.104.0 - github.com/pulumi/pulumi-azure-native-sdk/resources v1.104.0 github.com/pulumi/pulumi-gcp/sdk/v8 v8.39.0 github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.18.2 github.com/pulumi/pulumi/sdk/v3 v3.175.0 @@ -65,7 +63,6 @@ require ( github.com/pkg/term v1.1.0 // indirect github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect github.com/pulumi/esc v0.14.2 // indirect - github.com/pulumi/pulumi-azure-native-sdk v1.104.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect diff --git a/deploy/go.sum b/deploy/go.sum index 1beb116..1a01a08 100644 --- a/deploy/go.sum +++ b/deploy/go.sum @@ -154,12 +154,6 @@ github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435 github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= github.com/pulumi/esc v0.14.2 h1:xHpjJXzKs1hk/QPpgwe1Rmif3VWA0QcZ7jDvTFYX/jM= github.com/pulumi/esc v0.14.2/go.mod h1:0dNzCWIiRUmdfFrhHdeBzU4GiDPBhSfpeWDNApZwZ08= -github.com/pulumi/pulumi-azure-native-sdk v1.104.0 h1:vyD4PvKSOkwL1z9WTis3ZE9XC73UM/7AyMNek4Vm1+E= -github.com/pulumi/pulumi-azure-native-sdk v1.104.0/go.mod h1:ZfkbJPR8poiJgy4IlNaa2NBjHLW37nsLY2BIbZp3lHc= -github.com/pulumi/pulumi-azure-native-sdk/containerservice v1.104.0 h1:grLVzWH6pS5os8ZfAbEkdEbaF4BFoLMwDai9ZsOINqo= -github.com/pulumi/pulumi-azure-native-sdk/containerservice v1.104.0/go.mod h1:QRMkKXRX3suaDR13VgN9jEkPl66YI/MZOyu0hxZzwuk= -github.com/pulumi/pulumi-azure-native-sdk/resources v1.104.0 h1:oaqgOMuGswJooAyFFWkSn9r/m1IBVBxbEL7LIXgTjqI= -github.com/pulumi/pulumi-azure-native-sdk/resources v1.104.0/go.mod h1:CTbJkLYp5Foi5ccHeDfowJ+lpeX9ciaz16VeIVBhqng= github.com/pulumi/pulumi-gcp/sdk/v8 v8.39.0 h1:3i6MPUPGkltfOkl8UphrYne0D4h6RcJ2oKBP0eqGLS8= github.com/pulumi/pulumi-gcp/sdk/v8 v8.39.0/go.mod h1:MJPBwFykzyl5Lp70PP3Ds32y5XrWOPvNuDcfpdXPY8o= github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.18.2 h1:WKxxqw+94H4KhBWKRN79G9IhmBZewj8sPYQJclgHJx0= diff --git a/deploy/main.go b/deploy/main.go index b1f6967..0c77845 100644 --- a/deploy/main.go +++ b/deploy/main.go @@ -8,7 +8,6 @@ import ( "github.com/modelcontextprotocol/registry/deploy/infra/pkg/k8s" "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers" - "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers/aks" "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers/gcp" "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers/local" ) @@ -22,8 +21,6 @@ func createProvider(ctx *pulumi.Context) (providers.ClusterProvider, error) { } switch providerName { - case "aks": - return &aks.Provider{}, nil case "gcp": return &gcp.Provider{}, nil case "local": @@ -51,8 +48,14 @@ func main() { return err } + // Create backup storage + storage, err := provider.CreateBackupStorage(ctx, cluster, environment) + if err != nil { + return err + } + // Deploy to Kubernetes - _, err = k8s.DeployAll(ctx, cluster, environment) + _, err = k8s.DeployAll(ctx, cluster, storage, environment) if err != nil { return err } diff --git a/deploy/pkg/k8s/backup.go b/deploy/pkg/k8s/backup.go new file mode 100644 index 0000000..aadb6ed --- /dev/null +++ b/deploy/pkg/k8s/backup.go @@ -0,0 +1,136 @@ +package k8s + +import ( + "fmt" + + "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions" + corev1 "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-kubernetes/sdk/v4/go/kubernetes/yaml" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers" +) + +// DeployK8up installs the k8up backup operator and configures scheduled backups +func DeployK8up(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string, storage *providers.BackupStorageInfo) error { + if storage == nil { + ctx.Log.Info("No backup storage configured, skipping k8up deployment", nil) + return nil + } + + // Install the k8up CRDs before the helm chart + // Related: https://github.com/k8up-io/k8up/issues/1050 + k8upCRDs, err := yaml.NewConfigFile(ctx, "k8up-crds", &yaml.ConfigFileArgs{ + File: "https://github.com/k8up-io/k8up/releases/download/k8up-4.8.4/k8up-crd.yaml", + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return fmt.Errorf("failed to install k8up CRDs: %w", err) + } + + // Install k8up operator + k8upValues := pulumi.Map{ + "k8up": pulumi.Map{ + "backupCommandAnnotation": pulumi.String("k8up.io/backup-command"), + "fileExtensionAnnotation": pulumi.String("k8up.io/file-extension"), + }, + } + + k8up, err := helm.NewChart(ctx, "k8up", helm.ChartArgs{ + Chart: pulumi.String("k8up"), + Version: pulumi.String("4.8.4"), + FetchArgs: helm.FetchArgs{ + Repo: pulumi.String("https://k8up-io.github.io/k8up"), + }, + Values: k8upValues, + }, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{k8upCRDs})) + if err != nil { + return fmt.Errorf("failed to install k8up: %w", err) + } + + // Create restic repository password secret + repoPassword, err := corev1.NewSecret(ctx, "k8up-repo-password", &corev1.SecretArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("k8up-repo-password"), + Namespace: pulumi.String("default"), + Labels: pulumi.StringMap{ + "k8up.io/backup": pulumi.String("true"), + }, + }, + Type: pulumi.String("Opaque"), + StringData: pulumi.StringMap{ + "password": pulumi.String("password"), // In production we use GCS, which is already encrypted + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return fmt.Errorf("failed to create repository password secret: %w", err) + } + + // Determine schedule based on environment + backupSchedule := "46 4 * * *" // Daily at 4:46 AM + pruneSchedule := "46 5 * * *" // Daily at 5:46 AM + keepDaily := 28 // Keep daily backups for 28 days + + if environment == "local" || environment == "dev" { + backupSchedule = "* * * * *" // Every minute for testing + pruneSchedule = "*/5 * * * *" // Every 5 minutes + keepDaily = 1 + } + + // Create Schedule for automated backups + _, err = apiextensions.NewCustomResource(ctx, "k8up-schedule", &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("k8up.io/v1"), + Kind: pulumi.String("Schedule"), + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("backup-schedule"), + Namespace: pulumi.String("default"), + Labels: pulumi.StringMap{ + "environment": pulumi.String(environment), + }, + }, + OtherFields: map[string]any{ + "spec": map[string]any{ + "backend": map[string]any{ + "repoPasswordSecretRef": map[string]any{ + "name": repoPassword.Metadata.Name().Elem(), + "key": "password", + }, + "s3": map[string]any{ + "endpoint": storage.Endpoint, + "bucket": storage.BucketName, + "accessKeyIDSecretRef": map[string]any{ + "name": storage.Credentials.Metadata.Name().Elem(), + "key": "AWS_ACCESS_KEY_ID", + }, + "secretAccessKeySecretRef": map[string]any{ + "name": storage.Credentials.Metadata.Name().Elem(), + "key": "AWS_SECRET_ACCESS_KEY", + }, + }, + }, + "backup": map[string]any{ + "schedule": backupSchedule, + "podSecurityContext": map[string]any{ + "runAsUser": 0, // Run as root to access all files + }, + "successfulJobsHistoryLimit": 3, + "failedJobsHistoryLimit": 3, + }, + "prune": map[string]any{ + "schedule": pruneSchedule, + "retention": map[string]any{ + "keepDaily": keepDaily, + }, + "successfulJobsHistoryLimit": 1, + "failedJobsHistoryLimit": 1, + }, + }, + }, + }, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{k8up, storage.Credentials, repoPassword})) + if err != nil { + return fmt.Errorf("failed to create k8up schedule: %w", err) + } + + return nil +} diff --git a/deploy/pkg/k8s/deploy.go b/deploy/pkg/k8s/deploy.go index 3054eeb..f8b1142 100644 --- a/deploy/pkg/k8s/deploy.go +++ b/deploy/pkg/k8s/deploy.go @@ -8,7 +8,7 @@ import ( ) // DeployAll orchestrates the complete deployment of the MCP Registry to Kubernetes -func DeployAll(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (service *corev1.Service, err error) { +func DeployAll(ctx *pulumi.Context, cluster *providers.ProviderInfo, backupStorage *providers.BackupStorageInfo, environment string) (service *corev1.Service, err error) { // Setup cert-manager err = SetupCertManager(ctx, cluster) if err != nil { @@ -27,6 +27,12 @@ func DeployAll(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment return nil, err } + // Deploy k8up backup operator + err = DeployK8up(ctx, cluster, environment, backupStorage) + if err != nil { + return nil, err + } + // Deploy MCP Registry service, err = DeployMCPRegistry(ctx, cluster, environment, ingressNginx, pgCluster) if err != nil { diff --git a/deploy/pkg/k8s/ingress.go b/deploy/pkg/k8s/ingress.go index a07c988..abfe79f 100644 --- a/deploy/pkg/k8s/ingress.go +++ b/deploy/pkg/k8s/ingress.go @@ -30,6 +30,16 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo return nil, err } + // Usually we should expose the ingress to a LoadBalancer + // This works in GCP and most local setups e.g. minikube (with minikube tunnel) + // Kind unfortunately does not support LoadBalancer type, and hangs indefinitely. This is a workaround for that. + serviceType := cluster.Name.ApplyT(func(name string) string { + if name == "kind-kind" { + return "NodePort" + } + return "LoadBalancer" + }).(pulumi.StringOutput) + // Install NGINX Ingress Controller ingressNginx, err := helm.NewChart(ctx, "ingress-nginx", helm.ChartArgs{ Chart: pulumi.String("ingress-nginx"), @@ -41,7 +51,7 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo Values: pulumi.Map{ "controller": pulumi.Map{ "service": pulumi.Map{ - "type": pulumi.String("LoadBalancer"), + "type": serviceType, "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"), diff --git a/deploy/pkg/k8s/postgres.go b/deploy/pkg/k8s/postgres.go index fcf94e2..664c32f 100644 --- a/deploy/pkg/k8s/postgres.go +++ b/deploy/pkg/k8s/postgres.go @@ -40,8 +40,7 @@ func DeployPostgresDatabases(ctx *pulumi.Context, cluster *providers.ProviderInf return nil, err } - // Create PostgreSQL cluster with proper timeout handling - // Note: This may fail on first run until CloudNativePG operator is fully ready + // Create PostgreSQL cluster pgCluster, err := apiextensions.NewCustomResource(ctx, "registry-pg", &apiextensions.CustomResourceArgs{ ApiVersion: pulumi.String("postgresql.cnpg.io/v1"), Kind: pulumi.String("Cluster"), @@ -67,4 +66,4 @@ func DeployPostgresDatabases(ctx *pulumi.Context, cluster *providers.ProviderInf } return pgCluster, nil -} +} \ No newline at end of file diff --git a/deploy/pkg/providers/aks/provider.go b/deploy/pkg/providers/aks/provider.go deleted file mode 100644 index ad5c759..0000000 --- a/deploy/pkg/providers/aks/provider.go +++ /dev/null @@ -1,99 +0,0 @@ -package aks - -import ( - "encoding/base64" - "fmt" - - "github.com/pulumi/pulumi-azure-native-sdk/containerservice" - "github.com/pulumi/pulumi-azure-native-sdk/resources" - "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 Azure Kubernetes Service -type Provider struct{} - -// CreateCluster creates an Azure Kubernetes Service cluster -func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*providers.ProviderInfo, error) { - // Get resource group name from config or use default - conf := config.New(ctx, "mcp-registry") - resourceGroupName := conf.Get("resourceGroupName") - if resourceGroupName == "" { - resourceGroupName = "official-mcp-registry-prod" - } - - // Get existing resource group - resourceGroup, err := resources.LookupResourceGroup(ctx, &resources.LookupResourceGroupArgs{ - ResourceGroupName: resourceGroupName, - }) - if err != nil { - return nil, fmt.Errorf("failed to find resource group '%s': %w", resourceGroupName, err) - } - - // Create AKS cluster - clusterName := fmt.Sprintf("mcp-registry-%s", environment) - - cluster, err := containerservice.NewManagedCluster(ctx, clusterName, &containerservice.ManagedClusterArgs{ - ResourceGroupName: pulumi.String(resourceGroup.Name), - Location: pulumi.String(resourceGroup.Location), - DnsPrefix: pulumi.String(clusterName), - KubernetesVersion: pulumi.String("1.33.2"), - Identity: &containerservice.ManagedClusterIdentityArgs{ - Type: containerservice.ResourceIdentityTypeSystemAssigned, - }, - AgentPoolProfiles: containerservice.ManagedClusterAgentPoolProfileArray{ - &containerservice.ManagedClusterAgentPoolProfileArgs{ - Name: pulumi.String("nodepool1"), - Count: pulumi.Int(2), - VmSize: pulumi.String("Standard_B2s"), - OsType: pulumi.String("Linux"), - Mode: pulumi.String("System"), - }, - }, - NetworkProfile: &containerservice.ContainerServiceNetworkProfileArgs{ - NetworkPlugin: pulumi.String("azure"), - ServiceCidr: pulumi.String("10.0.0.0/16"), - DnsServiceIP: pulumi.String("10.0.0.10"), - }, - }) - if err != nil { - return nil, err - } - - // Get AKS credentials - creds := pulumi.All(cluster.Name, pulumi.String(resourceGroup.Name)).ApplyT( - func(args []any) (string, error) { - clusterName := args[0].(string) - rgName := args[1].(string) - credentials, err := containerservice.ListManagedClusterUserCredentials(ctx, &containerservice.ListManagedClusterUserCredentialsArgs{ - ResourceGroupName: rgName, - ResourceName: clusterName, - }) - if err != nil { - return "", err - } - // Decode base64 kubeconfig - kubeconfigData, err := base64.StdEncoding.DecodeString(credentials.Kubeconfigs[0].Value) - if err != nil { - return "", fmt.Errorf("failed to decode kubeconfig: %w", err) - } - return string(kubeconfigData), nil - }, - ).(pulumi.StringOutput) - - // Create Kubernetes provider for AKS - k8sProvider, err := kubernetes.NewProvider(ctx, "k8s-provider", &kubernetes.ProviderArgs{ - Kubeconfig: creds, - }) - if err != nil { - return nil, err - } - - return &providers.ProviderInfo{ - Name: cluster.Name, - Provider: k8sProvider, - }, nil -} diff --git a/deploy/pkg/providers/gcp/provider.go b/deploy/pkg/providers/gcp/provider.go index 70d7b94..365f2fd 100644 --- a/deploy/pkg/providers/gcp/provider.go +++ b/deploy/pkg/providers/gcp/provider.go @@ -6,7 +6,10 @@ import ( "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container" + "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage" "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" + corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" + metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" @@ -16,12 +19,51 @@ import ( // Provider implements the ClusterProvider interface for Google Kubernetes Engine type Provider struct{} +// createGCPProvider creates a GCP provider with explicit credentials if configured +func createGCPProvider(ctx *pulumi.Context, name string) (*gcp.Provider, error) { + gcpConf := config.New(ctx, "gcp") + + // Get project ID from config + 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 + if credentials != "" { + return gcp.NewProvider(ctx, name, &gcp.ProviderArgs{ + Project: pulumi.String(projectID), + Region: pulumi.String(region), + Credentials: pulumi.String(credentials), + }) + } + + return nil, nil +} + // 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 + // Get project ID from config projectID := gcpConf.Get("project") if projectID == "" { return nil, fmt.Errorf("GCP project ID not configured. Set gcp:project") @@ -33,29 +75,10 @@ func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*prov 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 GCP provider with explicit credentials if configured + gcpProvider, err := createGCPProvider(ctx, "gcp-explicit") + if err != nil { + return nil, err } // Create GKE cluster @@ -180,3 +203,102 @@ users: Provider: k8sProvider, }, nil } + +// CreateBackupStorage creates GCS bucket with HMAC credentials for S3-compatible access +func (p *Provider) CreateBackupStorage(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (*providers.BackupStorageInfo, error) { + gcpConf := config.New(ctx, "gcp") + projectID := gcpConf.Get("project") + if projectID == "" { + return nil, fmt.Errorf("GCP project ID not configured. Set gcp:project") + } + + // Create GCP provider with explicit credentials if configured + gcpProvider, err := createGCPProvider(ctx, "gcp-explicit-backup") + if err != nil { + return nil, err + } + + // Set resource options with provider if we have explicit credentials + resourceOpts := []pulumi.ResourceOption{} + if gcpProvider != nil { + resourceOpts = append(resourceOpts, pulumi.Provider(gcpProvider)) + } + + // Create GCS bucket for backups + bucketName := fmt.Sprintf("mcp-registry-%s-backups", environment) + bucket, err := storage.NewBucket(ctx, "backup-bucket", &storage.BucketArgs{ + Name: pulumi.String(bucketName), + Location: pulumi.String("US"), + StorageClass: pulumi.String("STANDARD"), + LifecycleRules: storage.BucketLifecycleRuleArray{ + &storage.BucketLifecycleRuleArgs{ + Action: &storage.BucketLifecycleRuleActionArgs{ + Type: pulumi.String("Delete"), + }, + Condition: &storage.BucketLifecycleRuleConditionArgs{ + Age: pulumi.Int(60), // Keep backups for 60 days (K8up manages pruning at 28 days, GCS deletion is a safety net) + }, + }, + }, + UniformBucketLevelAccess: pulumi.Bool(true), + Versioning: &storage.BucketVersioningArgs{ + Enabled: pulumi.Bool(true), + }, + Labels: pulumi.StringMap{ + "environment": pulumi.String(environment), + "purpose": pulumi.String("k8up-backups"), + }, + }, resourceOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create backup bucket: %w", err) + } + + // Use the existing Pulumi service account instead of creating a new one + // The service account email is pulumi-svc@.iam.gserviceaccount.com + serviceAccountEmail := fmt.Sprintf("pulumi-svc@%s.iam.gserviceaccount.com", projectID) + + // Grant the service account access to the bucket + _, err = storage.NewBucketIAMMember(ctx, "backup-bucket-iam", &storage.BucketIAMMemberArgs{ + Bucket: bucket.Name, + Role: pulumi.String("roles/storage.objectAdmin"), + Member: pulumi.Sprintf("serviceAccount:%s", pulumi.String(serviceAccountEmail)), + }, resourceOpts...) + if err != nil { + return nil, fmt.Errorf("failed to grant bucket access: %w", err) + } + + // Create HMAC key for S3-compatible access + hmacKey, err := storage.NewHmacKey(ctx, "backup-hmac-key", &storage.HmacKeyArgs{ + ServiceAccountEmail: pulumi.String(serviceAccountEmail), + Project: pulumi.String(projectID), + }, resourceOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC key: %w", err) + } + + // Create Kubernetes secret with S3-compatible credentials + backupSecret, err := corev1.NewSecret(ctx, "k8up-backup-credentials", &corev1.SecretArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("k8up-backup-credentials"), + Namespace: pulumi.String("default"), + Labels: pulumi.StringMap{ + "k8up.io/backup": pulumi.String("true"), + "environment": pulumi.String(environment), + }, + }, + Type: pulumi.String("Opaque"), + StringData: pulumi.StringMap{ + "AWS_ACCESS_KEY_ID": hmacKey.AccessId, + "AWS_SECRET_ACCESS_KEY": hmacKey.Secret, + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create backup credentials secret: %w", err) + } + + return &providers.BackupStorageInfo{ + Endpoint: "https://storage.googleapis.com", + BucketName: bucketName, + Credentials: backupSecret, + }, nil +} diff --git a/deploy/pkg/providers/local/provider.go b/deploy/pkg/providers/local/provider.go index f74bb76..1244a93 100644 --- a/deploy/pkg/providers/local/provider.go +++ b/deploy/pkg/providers/local/provider.go @@ -1,7 +1,15 @@ package local import ( + "fmt" + "os/exec" + "strings" + "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" + appsv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apps/v1" + batchv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/batch/v1" + corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" + metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers" @@ -10,6 +18,17 @@ import ( // Provider implements the ClusterProvider interface for local Kubernetes clusters type Provider struct{} +// getClusterName attempts to get the current Kubernetes cluster name from kubectl config +func getClusterName() string { + cmd := exec.Command("kubectl", "config", "current-context") + output, err := cmd.Output() + if err != nil { + // Fallback to default if we can't get the context + return "local" + } + return strings.TrimSpace(string(output)) +} + // CreateCluster configures access to a local Kubernetes cluster via kubeconfig func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*providers.ProviderInfo, error) { // Create Kubernetes provider for local cluster @@ -18,8 +37,237 @@ func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*prov return nil, err } + // Get the actual cluster name from kubectl config + clusterName := getClusterName() + return &providers.ProviderInfo{ - Name: pulumi.String("local").ToStringOutput(), + Name: pulumi.String(clusterName).ToStringOutput(), Provider: k8sProvider, }, nil } + +// CreateBackupStorage creates MinIO for S3-compatible backup storage +func (p *Provider) CreateBackupStorage(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (*providers.BackupStorageInfo, error) { + // Create MinIO namespace + minioNamespace, err := corev1.NewNamespace(ctx, "minio", &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio"), + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO namespace: %w", err) + } + + // Create MinIO credentials secret + minioSecret, err := corev1.NewSecret(ctx, "minio-credentials", &corev1.SecretArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio-credentials"), + Namespace: minioNamespace.Metadata.Name(), + }, + Type: pulumi.String("Opaque"), + StringData: pulumi.StringMap{ + "accesskey": pulumi.String("minioadmin"), + "secretkey": pulumi.String("minioadmin"), + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO credentials: %w", err) + } + + // Create MinIO PVC for data storage + _, err = corev1.NewPersistentVolumeClaim(ctx, "minio-pvc", &corev1.PersistentVolumeClaimArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio-pvc"), + Namespace: minioNamespace.Metadata.Name(), + }, + Spec: &corev1.PersistentVolumeClaimSpecArgs{ + AccessModes: pulumi.StringArray{ + pulumi.String("ReadWriteOnce"), + }, + Resources: &corev1.VolumeResourceRequirementsArgs{ + Requests: pulumi.StringMap{ + "storage": pulumi.String("10Gi"), + }, + }, + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO PVC: %w", err) + } + + // Deploy MinIO + minioDeployment, err := appsv1.NewDeployment(ctx, "minio", &appsv1.DeploymentArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio"), + Namespace: minioNamespace.Metadata.Name(), + }, + Spec: &appsv1.DeploymentSpecArgs{ + Replicas: pulumi.Int(1), + Selector: &metav1.LabelSelectorArgs{ + MatchLabels: pulumi.StringMap{ + "app": pulumi.String("minio"), + }, + }, + Template: &corev1.PodTemplateSpecArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Labels: pulumi.StringMap{ + "app": pulumi.String("minio"), + }, + }, + Spec: &corev1.PodSpecArgs{ + Containers: corev1.ContainerArray{ + &corev1.ContainerArgs{ + Name: pulumi.String("minio"), + Image: pulumi.String("minio/minio:RELEASE.2025-07-23T15-54-02Z"), + Args: pulumi.StringArray{ + pulumi.String("server"), + pulumi.String("/data"), + pulumi.String("--console-address"), + pulumi.String(":9001"), + }, + Env: corev1.EnvVarArray{ + &corev1.EnvVarArgs{ + Name: pulumi.String("MINIO_ROOT_USER"), + ValueFrom: &corev1.EnvVarSourceArgs{ + SecretKeyRef: &corev1.SecretKeySelectorArgs{ + Name: minioSecret.Metadata.Name(), + Key: pulumi.String("accesskey"), + }, + }, + }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MINIO_ROOT_PASSWORD"), + ValueFrom: &corev1.EnvVarSourceArgs{ + SecretKeyRef: &corev1.SecretKeySelectorArgs{ + Name: minioSecret.Metadata.Name(), + Key: pulumi.String("secretkey"), + }, + }, + }, + }, + Ports: corev1.ContainerPortArray{ + &corev1.ContainerPortArgs{ + ContainerPort: pulumi.Int(9000), + Name: pulumi.String("api"), + }, + &corev1.ContainerPortArgs{ + ContainerPort: pulumi.Int(9001), + Name: pulumi.String("console"), + }, + }, + VolumeMounts: corev1.VolumeMountArray{ + &corev1.VolumeMountArgs{ + Name: pulumi.String("data"), + MountPath: pulumi.String("/data"), + }, + }, + }, + }, + Volumes: corev1.VolumeArray{ + &corev1.VolumeArgs{ + Name: pulumi.String("data"), + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSourceArgs{ + ClaimName: pulumi.String("minio-pvc"), + }, + }, + }, + }, + }, + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO deployment: %w", err) + } + + // Create MinIO service + minioService, err := corev1.NewService(ctx, "minio-service", &corev1.ServiceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio"), + Namespace: minioNamespace.Metadata.Name(), + }, + Spec: &corev1.ServiceSpecArgs{ + Selector: pulumi.StringMap{ + "app": pulumi.String("minio"), + }, + Ports: corev1.ServicePortArray{ + &corev1.ServicePortArgs{ + Name: pulumi.String("api"), + Port: pulumi.Int(9000), + TargetPort: pulumi.Int(9000), + }, + &corev1.ServicePortArgs{ + Name: pulumi.String("console"), + Port: pulumi.Int(9001), + TargetPort: pulumi.Int(9001), + }, + }, + }, + }, pulumi.Provider(cluster.Provider)) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO service: %w", err) + } + + // Create a Job to initialize the MinIO bucket + _, err = batchv1.NewJob(ctx, "minio-bucket-init", &batchv1.JobArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("minio-bucket-init"), + Namespace: minioNamespace.Metadata.Name(), + }, + Spec: &batchv1.JobSpecArgs{ + Template: &corev1.PodTemplateSpecArgs{ + Spec: &corev1.PodSpecArgs{ + RestartPolicy: pulumi.String("OnFailure"), + Containers: corev1.ContainerArray{ + &corev1.ContainerArgs{ + Name: pulumi.String("mc"), + Image: pulumi.String("minio/mc:RELEASE.2025-07-19T03-52-40Z"), + Command: pulumi.StringArray{ + pulumi.String("/bin/sh"), + pulumi.String("-c"), + }, + Args: pulumi.StringArray{ + pulumi.String(` + until mc alias set myminio http://minio:9000 minioadmin minioadmin; do + echo 'Waiting for MinIO to be ready...' + sleep 5 + done + mc mb -p myminio/k8up-backups + echo 'Bucket created successfully' + `), + }, + }, + }, + }, + }, + }, + }, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{minioService})) + if err != nil { + return nil, fmt.Errorf("failed to create bucket initialization job: %w", err) + } + + // Create backup credentials secret for S3 access + backupSecret, err := corev1.NewSecret(ctx, "k8up-backup-credentials", &corev1.SecretArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("k8up-backup-credentials"), + Namespace: pulumi.String("default"), + Labels: pulumi.StringMap{ + "k8up.io/backup": pulumi.String("true"), + "environment": pulumi.String(environment), + }, + }, + Type: pulumi.String("Opaque"), + StringData: pulumi.StringMap{ + "AWS_ACCESS_KEY_ID": pulumi.String("minioadmin"), + "AWS_SECRET_ACCESS_KEY": pulumi.String("minioadmin"), + }, + }, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{minioDeployment})) + if err != nil { + return nil, fmt.Errorf("failed to create backup credentials secret: %w", err) + } + + return &providers.BackupStorageInfo{ + Endpoint: "http://minio.minio:9000", + BucketName: "k8up-backups", + Credentials: backupSecret, + }, nil +} diff --git a/deploy/pkg/providers/types.go b/deploy/pkg/providers/types.go index d977ff4..d2a6787 100644 --- a/deploy/pkg/providers/types.go +++ b/deploy/pkg/providers/types.go @@ -2,6 +2,7 @@ package providers import ( "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" + corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) @@ -11,8 +12,18 @@ type ProviderInfo struct { Provider *kubernetes.Provider } +// BackupStorageInfo represents S3-compatible backup storage configuration +type BackupStorageInfo struct { + Endpoint string // S3 endpoint (e.g., https://storage.googleapis.com or http://minio:9000) + BucketName string + Credentials *corev1.Secret // Should contain AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY +} + // ClusterProvider defines the interface that all cluster providers must implement type ClusterProvider interface { // CreateCluster creates a Kubernetes cluster and returns provider info CreateCluster(ctx *pulumi.Context, environment string) (*ProviderInfo, error) + + // CreateBackupStorage creates backup storage infrastructure + CreateBackupStorage(ctx *pulumi.Context, cluster *ProviderInfo, environment string) (*BackupStorageInfo, error) }