// Package main provides the entry point for the Pulumi GCP Fullstack application. package main import ( "log" "github.com/davidmontoyago/pulumi-gcp-fullstack/pkg/fullstack/gcp" "github.com/davidmontoyago/pulumi-gcp-fullstack/pkg/fullstack/gcp/config" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) func main() { pulumi.Run(func(ctx *pulumi.Context) error { // Load config helper cfg, err := config.LoadConfig() if err != nil { return err } // Create FullStack instance fullstack, err := gcp.NewFullStack(ctx, "my-fullstack", &gcp.FullStackArgs{ Project: cfg.GCPProject, Region: cfg.GCPRegion, BackendImage: pulumi.String(cfg.BackendImage), FrontendImage: pulumi.String(cfg.FrontendImage), Network: &gcp.NetworkArgs{ DomainURL: cfg.DomainURL, EnableCloudArmor: cfg.EnableCloudArmor, ClientIPAllowlist: cfg.ClientIPAllowlist, EnablePrivateTrafficOnly: cfg.EnablePrivateTrafficOnly, }, Labels: map[string]string{ "environment": "production", "managed-by": "pulumi", }, }) if err != nil { return err } // Export important outputs ctx.Export("backendServiceUrl", fullstack.GetBackendService().Uri) ctx.Export("frontendServiceUrl", fullstack.GetFrontendService().Uri) ctx.Export("apiGatewayUrl", fullstack.GetAPIGateway().DefaultHostname) log.Println("Fullstack deployment loaded and ready!") return nil }) }
// Package gcp provides Google Cloud Platform infrastructure components for fullstack applications. package gcp import ( "encoding/base64" "fmt" "log" "github.com/getkin/kin-openapi/openapi2conv" apigateway "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/apigateway" cloudrunv2 "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) // deployAPIGateway sets up Google API Gateway with the following features: // // - Dedicated service account for API Gateway // - API definition with OpenAPI spec // - API Config with backend routing to Cloud Run // - Regional gateways for external access // - CORS support for web applications // - Proper IAM permissions for API Gateway to invoke Cloud Run services // // See: // https://cloud.google.com/api-gateway/docs/gateway-serverless-neg // https://cloud.google.com/api-gateway/docs/gateway-load-balancing func (f *FullStack) deployAPIGateway(ctx *pulumi.Context, args *APIGatewayArgs) (*apigateway.Gateway, error) { if args == nil || args.Disabled { return nil, nil } if args.Config == nil { return nil, fmt.Errorf("APIConfigArgs is required when API Gateway is enabled") } if err := ctx.Log.Info(fmt.Sprintf("Routing traffic to API Gateway: %#v", args), nil); err != nil { log.Println("failed to log API Gateway args with Pulumi context: %w", err) } // Create API Gateway IAM resources (service account and permissions) gatewayServiceAccount, err := f.createAPIGatewayIAM(ctx, args.Name) if err != nil { return nil, fmt.Errorf("failed to create API Gateway IAM: %w", err) } apiID := f.newResourceName(args.Name, "api", 50) displayName := fmt.Sprintf("Gateway API (apiID: %s)", apiID) gatewayLabels := mergeLabels(f.Labels, pulumi.StringMap{ "gateway": pulumi.String("true"), }) // Create the API api, err := apigateway.NewApi(ctx, apiID, &apigateway.ApiArgs{ ApiId: pulumi.String(apiID), DisplayName: pulumi.String(displayName), Project: pulumi.String(f.Project), Labels: gatewayLabels, }) if err != nil { return nil, fmt.Errorf("failed to create API: %w", err) } ctx.Export("api_gateway_api_id", api.ApiId) ctx.Export("api_gateway_api_name", api.Name) apiConfig, err := f.createAPIConfig(ctx, apiID, args.Config, api, gatewayServiceAccount.Email, gatewayLabels) if err != nil { return nil, fmt.Errorf("failed to deploy API config: %w", err) } ctx.Export("api_gateway_config_id", apiConfig.ApiConfigId) ctx.Export("api_gateway_config_name", apiConfig.Name) // Create Gateway in the first region (or default region if none specified) region := f.Region if len(args.Regions) > 0 { region = args.Regions[0] } gatewayID := f.newResourceName(args.Name, "", 50) gatewayDisplayName := fmt.Sprintf("Gateway (gatewayID: %s)", gatewayID) gateway, err := apigateway.NewGateway(ctx, gatewayID, &apigateway.GatewayArgs{ GatewayId: pulumi.String(gatewayID), DisplayName: pulumi.String(gatewayDisplayName), Region: pulumi.String(region), Project: pulumi.String(f.Project), ApiConfig: apiConfig.ID(), Labels: gatewayLabels, }) if err != nil { return nil, fmt.Errorf("failed to create Gateway: %w", err) } ctx.Export("api_gateway_gateway_id", gateway.GatewayId) ctx.Export("api_gateway_gateway_name", gateway.Name) ctx.Export("api_gateway_gateway_default_hostname", gateway.DefaultHostname) f.apiGateway = gateway return gateway, nil } // createAPIGatewayIAM creates a dedicated service account for API Gateway and grants // it the necessary permissions to invoke Cloud Run services. // // This function ensures that the API Gateway has its own identity and can properly // route traffic to both backend and frontend Cloud Run services. func (f *FullStack) createAPIGatewayIAM(ctx *pulumi.Context, gatewayName string) (*serviceaccount.Account, error) { // Create dedicated service account for API Gateway apiGatewayAccountName := f.newResourceName(gatewayName, "account", 28) serviceAccount, err := serviceaccount.NewAccount(ctx, apiGatewayAccountName, &serviceaccount.AccountArgs{ AccountId: pulumi.String(apiGatewayAccountName), DisplayName: pulumi.String(fmt.Sprintf("API Gateway service account (%s)", gatewayName)), Project: pulumi.String(f.Project), }) if err != nil { return nil, fmt.Errorf("failed to create API Gateway service account: %w", err) } ctx.Export("api_gateway_service_account_id", serviceAccount.ID()) ctx.Export("api_gateway_service_account_email", serviceAccount.Email) f.gatewayServiceAccount = serviceAccount // Grant API Gateway service account permission to invoke Cloud Run services err = f.grantAPIGatewayInvokerPermissions(ctx, serviceAccount.Email, gatewayName) if err != nil { return nil, fmt.Errorf("failed to grant API Gateway invoker permissions: %w", err) } return serviceAccount, nil } // grantAPIGatewayInvokerPermissions grants the API Gateway service account // permission to invoke both backend and frontend Cloud Run services. // // This function ensures that the dedicated API Gateway service account can // properly route traffic to the Cloud Run services. func (f *FullStack) grantAPIGatewayInvokerPermissions(ctx *pulumi.Context, apiGatewayServiceAccountEmail pulumi.StringOutput, gatewayName string) error { // Grant API Gateway permission to invoke backend service backendInvokerName := f.newResourceName(gatewayName, "backend-invoker", 100) backendIamMember, err := cloudrunv2.NewServiceIamMember(ctx, backendInvokerName, &cloudrunv2.ServiceIamMemberArgs{ Name: f.backendService.Name, Project: pulumi.String(f.Project), Location: pulumi.String(f.Region), Role: pulumi.String("roles/run.invoker"), Member: pulumi.Sprintf("serviceAccount:%s", apiGatewayServiceAccountEmail), }) if err != nil { return fmt.Errorf("failed to grant API Gateway backend invoker permissions: %w", err) } f.backendGatewayIamMember = backendIamMember // Grant API Gateway permission to invoke frontend service frontendInvokerName := f.newResourceName(gatewayName, "frontend-invoker", 100) frontendIamMember, err := cloudrunv2.NewServiceIamMember(ctx, frontendInvokerName, &cloudrunv2.ServiceIamMemberArgs{ Name: f.frontendService.Name, Project: pulumi.String(f.Project), Location: pulumi.String(f.Region), Role: pulumi.String("roles/run.invoker"), Member: pulumi.Sprintf("serviceAccount:%s", apiGatewayServiceAccountEmail), }) if err != nil { return fmt.Errorf("failed to grant API Gateway frontend invoker permissions: %w", err) } f.frontendGatewayIamMember = frontendIamMember return nil } // createAPIConfig configures the API gateway, and sets the // Gateway service account email used to invoke the backend and frontend. // The OpenAPI spec document is responsible for mapping paths to the backend and // frontend services URLs. See Backend.ServiceURL and Frontend.ServiceURL. func (f *FullStack) createAPIConfig(ctx *pulumi.Context, apiID string, configArgs *APIConfigArgs, api *apigateway.Api, gatewayServiceAccountEmail pulumi.StringOutput, gatewayLabels pulumi.StringMap) (*apigateway.ApiConfig, error) { if configArgs == nil { return nil, fmt.Errorf("APIConfigArgs is required") } // Set default OpenAPI spec path if not provided openAPISpecPath := configArgs.OpenAPISpecPath if openAPISpecPath == "" { openAPISpecPath = "/openapi.yaml" } // Generate OpenAPI spec with backend routing openAPISpec := f.generateOpenAPISpec(ctx, configArgs) // Convert OpenAPI spec to base64 encoding base64OpenAPISpec := openAPISpec.ApplyT(func(spec string) string { return base64.StdEncoding.EncodeToString([]byte(spec)) }).(pulumi.StringOutput) // Create API Config apiConfig, err := apigateway.NewApiConfig(ctx, fmt.Sprintf("%s-config", apiID), &apigateway.ApiConfigArgs{ Api: api.ApiId, ApiConfigId: pulumi.String(fmt.Sprintf("%s-config", apiID)), DisplayName: pulumi.String(fmt.Sprintf("Config for %s", apiID)), Project: pulumi.String(f.Project), OpenapiDocuments: apigateway.ApiConfigOpenapiDocumentArray{ &apigateway.ApiConfigOpenapiDocumentArgs{ Document: &apigateway.ApiConfigOpenapiDocumentDocumentArgs{ Path: pulumi.String(openAPISpecPath), Contents: base64OpenAPISpec, }, }, }, GatewayConfig: &apigateway.ApiConfigGatewayConfigArgs{ BackendConfig: &apigateway.ApiConfigGatewayConfigBackendConfigArgs{ GoogleServiceAccount: gatewayServiceAccountEmail, }, }, Labels: gatewayLabels, }, pulumi.ReplaceOnChanges([]string{"*"})) if err != nil { return nil, fmt.Errorf("failed to create API config resource: %w", err) } f.apiConfig = apiConfig return apiConfig, nil } // generateOpenAPISpec creates a standard OpenAPI 3.0.1 specification for API Gateway // that routes all traffic to the Cloud Run backend service. This YAML boilerplate // is required by Google API Gateway to understand the API structure and routing rules. // // The specification includes: // - Proxy routing with {proxy+} path parameter to forward all requests // - Support for GET, POST, PUT, DELETE, OPTIONS HTTP methods // - CORS configuration for web applications // - Backend routing to Cloud Run service // // See: // https://cloud.google.com/api-gateway/docs/reference/rest/v1/projects.locations.apis.configs#OpenApiDocument func (f *FullStack) generateOpenAPISpec(ctx *pulumi.Context, configArgs *APIConfigArgs) pulumi.StringOutput { openAPISpec := pulumi.All( configArgs.Backend.ServiceURL, configArgs.Frontend.ServiceURL, f.frontendService.Template.ServiceAccount(), ).ApplyT(func(args []interface{}) (string, error) { backendURL := args[0].(string) frontendURL := args[1].(string) // If JWT is enabled, set config defaults frontendServiceAccountEmailPtr := args[2].(*string) if frontendServiceAccountEmailPtr != nil { applyJWTConfigDefaults(configArgs.Backend.JWTAuth, *frontendServiceAccountEmailPtr) } v3Spec := newOpenAPISpec(backendURL, frontendURL, configArgs, configArgs.Backend.JWTAuth) // Debug: Print v3 spec v3JSON, err := v3Spec.MarshalJSON() if err != nil { return "", fmt.Errorf("failed to marshal v3 spec: %w", err) } if err := ctx.Log.Debug(fmt.Sprintf("DEBUG: OpenAPI v3 spec:\n%s\n", string(v3JSON)), nil); err != nil { log.Printf("failed to log v3 spec with Pulumi context: %v", err) } // Convert OpenAPI 3 to OpenAPI 2 spec as expected by Google API Gateway // See: // - https://cloud.google.com/endpoints/docs/openapi // - https://github.com/cloudendpoints/esp/issues/446 v2Spec, err := openapi2conv.FromV3(v3Spec) if err != nil { return "", fmt.Errorf("failed to convert v3 to v2: %w", err) } // Debug: Print v2 spec v2JSON, err := v2Spec.MarshalJSON() if err != nil { return "", fmt.Errorf("failed to marshal v2 spec: %w", err) } if err := ctx.Log.Debug(fmt.Sprintf("DEBUG: OpenAPI v2 spec:\n%s\n", string(v2JSON)), nil); err != nil { log.Printf("failed to log v2 spec with Pulumi context: %v", err) } return string(v2JSON), nil }).(pulumi.StringOutput) return openAPISpec } // applyJWTConfigDefaults applies default JWT configuration values for service-to-service authentication func applyJWTConfigDefaults(jwtAuth *JWTAuth, frontendServiceAccountEmail string) { if jwtAuth != nil { if jwtAuth.Issuer == "" { jwtAuth.Issuer = frontendServiceAccountEmail } if jwtAuth.JwksURI == "" { jwtAuth.JwksURI = fmt.Sprintf("https://www.googleapis.com/service_accounts/v1/metadata/x509/%s", frontendServiceAccountEmail) } } } // applyDefaultGatewayArgs applies default API Gateway configuration to the provided args. // If the provided args is nil, it returns a new instance with default config. // If the provided args has a nil Config, it applies the default config. func applyDefaultGatewayArgs(args *APIGatewayArgs, backendServiceURL, frontendServiceURL pulumi.StringOutput) *APIGatewayArgs { var gatewayArgs *APIGatewayArgs if args == nil { gatewayArgs = &APIGatewayArgs{} } else { gatewayArgs = args } if gatewayArgs.Config == nil { gatewayArgs.Config = &APIConfigArgs{} } // Initialize Backend and Frontend if they are nil if gatewayArgs.Config.Backend == nil { gatewayArgs.Config.Backend = &Upstream{} } if gatewayArgs.Config.Frontend == nil { gatewayArgs.Config.Frontend = &Upstream{} } // Ignore any value given and set always to the services URLs gatewayArgs.Config.Backend.ServiceURL = backendServiceURL gatewayArgs.Config.Frontend.ServiceURL = frontendServiceURL if gatewayArgs.Name == "" { gatewayArgs.Name = "gateway" } return gatewayArgs }
// Package gcp provides Google Cloud Platform infrastructure components for fullstack applications. package gcp import ( "encoding/base64" "fmt" "log" "strings" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/redis" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/vpcaccess" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) // Env vars for Secret with Redis cache configuration // //nolint:revive // Environment variable names should match their actual env var names const ( REDIS_HOST = "REDIS_HOST" REDIS_PORT = "REDIS_PORT" REDIS_READ_HOST = "REDIS_READ_HOST" REDIS_READ_PORT = "REDIS_READ_PORT" REDIS_AUTH_STRING = "REDIS_AUTH_STRING" REDIS_TLS_CA_CERTS = "REDIS_TLS_CA_CERTS" ) // deployCache creates a Redis cache instance with private VPC access and firewall rules func (f *FullStack) deployCache(ctx *pulumi.Context, args *CacheInstanceArgs) error { if err := ctx.Log.Debug("Deploying Redis cache with config: %v", &pulumi.LogArgs{ Resource: f, }); err != nil { log.Printf("failed to log Redis cache deployment with pulumi context: %v", err) } redisAPI, err := f.enableRedisAPI(ctx) if err != nil { return fmt.Errorf("failed to enable Redis API: %w", err) } instance, err := f.createRedisInstance(ctx, args, redisAPI) if err != nil { return fmt.Errorf("failed to create Redis instance: %w", err) } // Create VPC access connector for Cloud Run to reach Redis' private IP connector, err := f.createVPCAccessConnector(ctx, instance.AuthorizedNetwork, args) if err != nil { return fmt.Errorf("failed to create VPC access connector: %w", err) } // Create firewall rule to allow Cloud Run to connect to Redis firewall, err := f.createCacheFirewallRule(ctx, connector, instance.Port, instance.AuthorizedNetwork) if err != nil { return fmt.Errorf("failed to create cache firewall rule: %w", err) } // Store Redis credentials in Secret Manager // Cloud run instance will automatically mount it as a volume secretVersion, err := f.secureCacheCredentials(ctx, instance) if err != nil { return fmt.Errorf("failed to secure cache credentials: %w", err) } f.redisInstance = instance f.vpcConnector = connector f.cacheFirewall = firewall f.cacheCredentialsSecret = secretVersion return nil } // enableRedisAPI enables the Redis API service func (f *FullStack) enableRedisAPI(ctx *pulumi.Context) (*projects.Service, error) { return projects.NewService(ctx, f.newResourceName("cache", "redis-api", 63), &projects.ServiceArgs{ Project: pulumi.String(f.Project), Service: pulumi.String("redis.googleapis.com"), }, pulumi.Parent(f)) } // createVPCAccessConnector creates a Serverless VPC Access connector for Cloud Run to reach private resources func (f *FullStack) createVPCAccessConnector(ctx *pulumi.Context, cacheNetwork pulumi.StringOutput, args *CacheInstanceArgs) (*vpcaccess.Connector, error) { // Enable VPC Access API vpcAPI, err := projects.NewService(ctx, f.newResourceName("cache", "vpcaccess-api", 63), &projects.ServiceArgs{ Project: pulumi.String(f.Project), Service: pulumi.String("vpcaccess.googleapis.com"), }, pulumi.Parent(f)) if err != nil { return nil, fmt.Errorf("failed to enable VPC access API: %w", err) } connectorName := f.newResourceName("cache", "private-connector", 25) return vpcaccess.NewConnector(ctx, connectorName, &vpcaccess.ConnectorArgs{ Name: pulumi.String(connectorName), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), Network: cacheNetwork.ApplyT(func(network string) pulumi.StringInput { return pulumi.String(network) }).(pulumi.StringInput), IpCidrRange: pulumi.String(func() string { if args.ConnectorIPCidrRange == "" { return "10.8.0.0/28" // fallback to default } return args.ConnectorIPCidrRange }()), MinInstances: pulumi.Int(args.ConnectorMinInstances), MaxInstances: pulumi.Int(args.ConnectorMaxInstances), }, pulumi.Parent(f), pulumi.DependsOn([]pulumi.Resource{vpcAPI})) } // createCacheFirewallRule creates a firewall rule to allow Cloud Run to connect to Redis func (f *FullStack) createCacheFirewallRule(ctx *pulumi.Context, connector *vpcaccess.Connector, instancePort pulumi.IntOutput, cacheNetwork pulumi.StringOutput) (*compute.Firewall, error) { firewall, err := compute.NewFirewall(ctx, f.newResourceName("cache", "firewall", 63), &compute.FirewallArgs{ Name: pulumi.String(f.newResourceName("cache", "allow-cloudrun-to-redis", 63)), Project: pulumi.String(f.Project), Network: cacheNetwork.ApplyT(func(network string) pulumi.StringInput { return pulumi.String(network) }).(pulumi.StringInput), Direction: pulumi.String("INGRESS"), Priority: pulumi.Int(1000), Allows: compute.FirewallAllowArray{ &compute.FirewallAllowArgs{ Protocol: pulumi.String("tcp"), Ports: pulumi.StringArray{instancePort.ApplyT(func(port int) string { return fmt.Sprintf("%d", port) }).(pulumi.StringOutput)}, }, }, SourceRanges: pulumi.StringArray{ connector.IpCidrRange.ApplyT(func(ipCidrRange *string) string { if ipCidrRange != nil { return *ipCidrRange } if err := ctx.Log.Warn("No IP CIDR range found for connector, using fallback", nil); err != nil { log.Printf("failed to log IP CIDR details with pulumi context: %v", err) } return "10.8.0.0/28" // fallback to default }).(pulumi.StringOutput), }, Description: pulumi.String("Allow TCP on Redis instace port from Cloud Run VPC Connector subnet"), }, pulumi.Parent(f)) if err != nil { return nil, fmt.Errorf("failed to create cache firewall rule: %w", err) } return firewall, nil } // createRedisInstance creates a Redis instance with auth and TLS enabled func (f *FullStack) createRedisInstance(ctx *pulumi.Context, config *CacheInstanceArgs, redisAPI *projects.Service) (*redis.Instance, error) { // Set defaults if not provided applyCacheConfigDefaults(config) return redis.NewInstance(ctx, f.newResourceName("cache", "instance", 63), &redis.InstanceArgs{ Name: pulumi.String(f.newResourceName("cache", "instance", 63)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), Tier: pulumi.String(config.Tier), MemorySizeGb: pulumi.Int(config.MemorySizeGb), RedisVersion: pulumi.String(config.RedisVersion), Labels: mergeLabels(f.Labels, pulumi.StringMap{"cache": pulumi.String("true")}), AuthorizedNetwork: pulumi.String(config.AuthorizedNetwork), AuthEnabled: pulumi.Bool(true), TransitEncryptionMode: pulumi.String("SERVER_AUTHENTICATION"), }, pulumi.Parent(f), pulumi.DependsOn([]pulumi.Resource{redisAPI})) } // secureCacheCredentials stores Redis connection details in Secret Manager func (f *FullStack) secureCacheCredentials(ctx *pulumi.Context, instance *redis.Instance) (*secretmanager.SecretVersion, error) { // Enable Secret Manager API secretManagerAPI, err := projects.NewService(ctx, f.newResourceName("cache", "secretmanager-api", 63), &projects.ServiceArgs{ Project: pulumi.String(f.Project), Service: pulumi.String("secretmanager.googleapis.com"), }, pulumi.Parent(f)) if err != nil { return nil, fmt.Errorf("failed to enable Secret Manager API: %w", err) } // Create secret to store Redis credentials secretID := f.newResourceName("cache", "creds", 100) secret, err := secretmanager.NewSecret(ctx, secretID, &secretmanager.SecretArgs{ Project: pulumi.String(f.Project), Replication: &secretmanager.SecretReplicationArgs{ // With google-managed default encryption Auto: &secretmanager.SecretReplicationAutoArgs{}, }, SecretId: pulumi.String(secretID), DeletionProtection: pulumi.Bool(false), Labels: mergeLabels(f.Labels, pulumi.StringMap{"cache-credentials": pulumi.String("true")}), }, pulumi.Parent(f), pulumi.DependsOn([]pulumi.Resource{secretManagerAPI}), ) if err != nil { return nil, fmt.Errorf("failed to create cache credentials secret: %w", err) } // Create dotenv format with Redis connection details dotenvData := createDotEnvSecretData(instance) // Create secret version with Redis credentials marked as sensitive return secretmanager.NewSecretVersion(ctx, f.newResourceName("cache", "credentials-version", 63), &secretmanager.SecretVersionArgs{ Secret: secret.ID(), SecretData: pulumi.ToSecret(dotenvData).(pulumi.StringOutput).ApplyT(func(s string) *string { return &s }).(pulumi.StringPtrOutput), }, pulumi.Parent(f), pulumi.DependsOn([]pulumi.Resource{secret})) } // createDotEnvSecretData creates dotenv format with Redis connection details func createDotEnvSecretData(instance *redis.Instance) pulumi.StringOutput { return pulumi.All( instance.Host, instance.Port, instance.ReadEndpoint, instance.ReadEndpointPort, instance.AuthString, instance.ServerCaCerts, ).ApplyT(func(args []interface{}) string { host := args[0].(string) port := args[1].(int) readEndpoint := args[2].(string) readEndpointPort := args[3].(int) authString := args[4].(string) serverCaCerts := args[5].([]redis.InstanceServerCaCert) // Concatenate all CA certificates if available var allCerts []string for _, cert := range serverCaCerts { if cert.Cert != nil && *cert.Cert != "" { allCerts = append(allCerts, *cert.Cert) } } concatenatedCerts := strings.Join(allCerts, "\n") // Base64 encode the concatenated certificates to avoid .env parsing issues encodedCerts := base64.StdEncoding.EncodeToString([]byte(concatenatedCerts)) return fmt.Sprintf("%s=%s\n%s=%d\n%s=%s\n%s=%d\n%s=%s\n%s=%s", REDIS_HOST, host, REDIS_PORT, port, REDIS_READ_HOST, readEndpoint, REDIS_READ_PORT, readEndpointPort, REDIS_AUTH_STRING, authString, REDIS_TLS_CA_CERTS, encodedCerts) }).(pulumi.StringOutput) } func applyCacheConfigDefaults(config *CacheInstanceArgs) { if config.RedisVersion == "" { config.RedisVersion = "REDIS_7_0" } if config.Tier == "" { config.Tier = "BASIC" } if config.MemorySizeGb == 0 { config.MemorySizeGb = 1 } if config.AuthorizedNetwork == "" { config.AuthorizedNetwork = "default" } if config.ConnectorMinInstances == 0 { config.ConnectorMinInstances = 2 } if config.ConnectorMaxInstances == 0 { config.ConnectorMaxInstances = 3 } }
package gcp import ( "fmt" cloudrunv2 "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" secretmanager "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) var ( // Resource "requests" do not apply to Cloud Run as in k8s defaultBackendResourceLimits = pulumi.StringMap{ "memory": pulumi.String("1Gi"), "cpu": pulumi.String("1000m"), } defaultFrontendResourceLimits = pulumi.StringMap{ "memory": pulumi.String("2Gi"), "cpu": pulumi.String("2000m"), } ) // InstanceDefaults contains the default values for different service types type InstanceDefaults struct { SecretConfigFileName string SecretConfigFilePath string ContainerPort int ResourceLimits pulumi.StringMap } // setInstanceDefaults takes an existing InstanceArgs (or nil) and returns a new one with safe defaults. // The defaults are customized based on the service type (backend vs frontend). func setInstanceDefaults(args *InstanceArgs, defaults InstanceDefaults) *InstanceArgs { if args == nil { args = &InstanceArgs{} } if args.SecretConfigFileName == "" { args.SecretConfigFileName = defaults.SecretConfigFileName } if args.SecretConfigFilePath == "" { args.SecretConfigFilePath = defaults.SecretConfigFilePath } if args.ResourceLimits == nil { args.ResourceLimits = defaults.ResourceLimits } if args.ContainerPort == 0 { args.ContainerPort = defaults.ContainerPort } if args.MaxInstanceCount == 0 { args.MaxInstanceCount = 3 } // Set default startup probe if not provided if args.StartupProbe == nil { args.StartupProbe = &Probe{ InitialDelaySeconds: 15, PeriodSeconds: 3, TimeoutSeconds: 1, FailureThreshold: 3, } } // Set default liveness probe if not provided if args.LivenessProbe == nil { args.LivenessProbe = &Probe{ Path: "healthz", InitialDelaySeconds: 30, PeriodSeconds: 10, TimeoutSeconds: 5, FailureThreshold: 3, } } if args.Secrets == nil { args.Secrets = []*SecretVolumeArgs{} } return args } func (f *FullStack) deployBackendCloudRunInstance(ctx *pulumi.Context, args *BackendArgs) (*cloudrunv2.Service, *serviceaccount.Account, error) { // Set defaults for backend backendDefaults := InstanceDefaults{ SecretConfigFileName: ".env", SecretConfigFilePath: "/app/config/", ContainerPort: 4001, ResourceLimits: defaultBackendResourceLimits, } if args == nil { args = &BackendArgs{} } args.InstanceArgs = setInstanceDefaults(args.InstanceArgs, backendDefaults) backendName := f.BackendName backendLabels := mergeLabels(f.Labels, pulumi.StringMap{ "backend": pulumi.String("true"), }) accountName := f.newResourceName(backendName, "account", 28) serviceAccount, err := serviceaccount.NewAccount(ctx, accountName, &serviceaccount.AccountArgs{ AccountId: pulumi.String(accountName), DisplayName: pulumi.String(fmt.Sprintf("Backend service account (%s)", backendName)), Project: pulumi.String(f.Project), }) if err != nil { return nil, nil, fmt.Errorf("failed to create backend service account: %w", err) } ctx.Export("cloud_run_service_backend_account_id", serviceAccount.ID()) additionalSecrets := args.Secrets if f.cacheCredentialsSecret != nil { // if enabled, append secret with cache credentials additionalSecrets = append(additionalSecrets, &SecretVolumeArgs{ SecretID: f.cacheCredentialsSecret.Secret, Name: "cache-credentials", Path: "/app/cache-config", SecretName: ".env", Version: f.cacheCredentialsSecret.Version, }) } volumes, volumeMounts, err := f.setupInstanceSecrets(ctx, backendName, additionalSecrets, serviceAccount, backendLabels, args.InstanceArgs) if err != nil { return nil, nil, fmt.Errorf("failed to setup instance secrets: %w", err) } backendServiceName := f.newResourceName(backendName, "service", 100) serviceTemplate := &cloudrunv2.ServiceTemplateArgs{ Scaling: &cloudrunv2.ServiceTemplateScalingArgs{ MaxInstanceCount: pulumi.Int(args.MaxInstanceCount), }, Containers: cloudrunv2.ServiceTemplateContainerArray{ &cloudrunv2.ServiceTemplateContainerArgs{ Image: f.BackendImage, Envs: newBackendEnvVars(args), Resources: &cloudrunv2.ServiceTemplateContainerResourcesArgs{ Limits: args.ResourceLimits, }, Ports: cloudrunv2.ServiceTemplateContainerPortsArgs{ ContainerPort: pulumi.Int(args.ContainerPort), }, StartupProbe: &cloudrunv2.ServiceTemplateContainerStartupProbeArgs{ TcpSocket: &cloudrunv2.ServiceTemplateContainerStartupProbeTcpSocketArgs{ Port: pulumi.Int(args.ContainerPort), }, InitialDelaySeconds: pulumi.Int(args.StartupProbe.InitialDelaySeconds), PeriodSeconds: pulumi.Int(args.StartupProbe.PeriodSeconds), TimeoutSeconds: pulumi.Int(args.StartupProbe.TimeoutSeconds), FailureThreshold: pulumi.Int(args.StartupProbe.FailureThreshold), }, LivenessProbe: &cloudrunv2.ServiceTemplateContainerLivenessProbeArgs{ HttpGet: &cloudrunv2.ServiceTemplateContainerLivenessProbeHttpGetArgs{ Path: pulumi.String(fmt.Sprintf("/%s", args.LivenessProbe.Path)), Port: pulumi.Int(args.ContainerPort), }, InitialDelaySeconds: pulumi.Int(args.LivenessProbe.InitialDelaySeconds), PeriodSeconds: pulumi.Int(args.LivenessProbe.PeriodSeconds), TimeoutSeconds: pulumi.Int(args.LivenessProbe.TimeoutSeconds), FailureThreshold: pulumi.Int(args.LivenessProbe.FailureThreshold), }, VolumeMounts: volumeMounts, }, }, ServiceAccount: serviceAccount.Email, Volumes: volumes, } if f.vpcConnector != nil { // Access to cache instance with private IP serviceTemplate.VpcAccess = &cloudrunv2.ServiceTemplateVpcAccessArgs{ Connector: f.vpcConnector.SelfLink, Egress: pulumi.String("PRIVATE_RANGES_ONLY"), } } backendService, err := cloudrunv2.NewService(ctx, backendServiceName, &cloudrunv2.ServiceArgs{ Name: pulumi.String(backendServiceName), Ingress: pulumi.String("INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"), Description: pulumi.String(fmt.Sprintf("Serverless instance (%s)", backendName)), Location: pulumi.String(f.Region), Project: pulumi.String(f.Project), Labels: backendLabels, Template: serviceTemplate, DeletionProtection: pulumi.Bool(args.DeletionProtection), }) if err != nil { return nil, nil, fmt.Errorf("failed to create backend Cloud Run service: %w", err) } ctx.Export("cloud_run_service_backend_id", backendService.ID()) ctx.Export("cloud_run_service_backend_uri", backendService.Uri) err = f.grantProjectLevelIAMRoles(ctx, args.ProjectIAMRoles, backendServiceName, serviceAccount) if err != nil { return nil, nil, fmt.Errorf("failed to grant project level IAM roles to backend Cloud Run service: %w", err) } return backendService, serviceAccount, nil } func (f *FullStack) mountSecrets(ctx *pulumi.Context, secrets []*SecretVolumeArgs, backendName string, serviceAccountEmail pulumi.StringOutput, ) (*cloudrunv2.ServiceTemplateVolumeArray, *cloudrunv2.ServiceTemplateContainerVolumeMountArray, error) { volumes := &cloudrunv2.ServiceTemplateVolumeArray{} volumeMounts := &cloudrunv2.ServiceTemplateContainerVolumeMountArray{} for _, secret := range secrets { // mount secret as a container volume secretVolume := newSecretVolume(secret) *volumes = append(*volumes, secretVolume) *volumeMounts = append(*volumeMounts, cloudrunv2.ServiceTemplateContainerVolumeMountArgs{ MountPath: pulumi.String(secret.Path), Name: pulumi.String(secret.Name), }) // Create IAM binding for the secret (similar to secretmanager.go) secretAccessorName := f.newResourceName(backendName, fmt.Sprintf("%s-secret-accessor", secret.Name), 100) secretAccessor, err := secretmanager.NewSecretIamMember(ctx, secretAccessorName, &secretmanager.SecretIamMemberArgs{ Project: pulumi.String(f.Project), SecretId: secret.SecretID, Role: pulumi.String("roles/secretmanager.secretAccessor"), Member: pulumi.Sprintf("serviceAccount:%s", serviceAccountEmail), }) if err != nil { return nil, nil, fmt.Errorf("failed to grant secret accessor for %s: %w", secret.Name, err) } ctx.Export(fmt.Sprintf("cloud_run_service_backend_secret_member_%s_id", secret.Name), secretAccessor.ID()) } return volumes, volumeMounts, nil } // setupInstanceSecrets creates the configuration secret and sets up all secret volumes and mounts for a service instance func (f *FullStack) setupInstanceSecrets( ctx *pulumi.Context, serviceName string, secrets []*SecretVolumeArgs, serviceAccount *serviceaccount.Account, labels pulumi.StringMap, args *InstanceArgs, ) (*cloudrunv2.ServiceTemplateVolumeArray, *cloudrunv2.ServiceTemplateContainerVolumeMountArray, error) { // create a secret to hold env vars for the cloud run instance configSecret, err := f.newEnvConfigSecret(ctx, serviceName, serviceAccount, args.DeletionProtection, labels, ) if err != nil { return nil, nil, fmt.Errorf("failed to create config secret: %w", err) } // add the volume for the default config volumes := &cloudrunv2.ServiceTemplateVolumeArray{ newSecretVolume(&SecretVolumeArgs{ SecretID: configSecret.SecretId, Name: "envconfig", Path: args.SecretConfigFileName, Version: pulumi.String("latest"), }), } volumeMounts := &cloudrunv2.ServiceTemplateContainerVolumeMountArray{ cloudrunv2.ServiceTemplateContainerVolumeMountArgs{ MountPath: pulumi.String(args.SecretConfigFilePath), Name: pulumi.String("envconfig"), }, } // add other secrets passed if len(secrets) > 0 { moreVolumes, moreMounts, err := f.mountSecrets(ctx, secrets, serviceName, serviceAccount.Email) if err != nil { return nil, nil, fmt.Errorf("failed to mount additional secrets: %w", err) } *volumes = append(*volumes, *moreVolumes...) *volumeMounts = append(*volumeMounts, *moreMounts...) } return volumes, volumeMounts, nil } func (f *FullStack) grantProjectLevelIAMRoles(ctx *pulumi.Context, iamRoles []string, backendServiceName string, serviceAccount *serviceaccount.Account) error { instanceRoles := iamRoles if f.redisInstance != nil { // Allow backend to write to Redis instance instanceRoles = append(instanceRoles, "roles/redis.editor") } if len(instanceRoles) > 0 { for _, role := range instanceRoles { iamMember, err := projects.NewIAMMember(ctx, fmt.Sprintf("%s-%s", backendServiceName, role), &projects.IAMMemberArgs{ Project: pulumi.String(f.Project), Role: pulumi.String(role), Member: pulumi.Sprintf("serviceAccount:%s", serviceAccount.Email), }) if err != nil { return fmt.Errorf("failed to add IAM role to backend Cloud Run service: %w", err) } // Track created IAM members for testing/inspection f.backendProjectIamMembers = append(f.backendProjectIamMembers, iamMember) ctx.Export(fmt.Sprintf("cloud_run_service_backend_iam_member_%s", role), iamMember.ID()) } } return nil } func (f *FullStack) deployFrontendCloudRunInstance(ctx *pulumi.Context, args *FrontendArgs, backendURL pulumi.StringOutput) (*cloudrunv2.Service, *serviceaccount.Account, error) { // Set defaults for frontend frontendDefaults := InstanceDefaults{ SecretConfigFileName: ".env.production", SecretConfigFilePath: "/app/.next/config/", ContainerPort: 3000, ResourceLimits: defaultFrontendResourceLimits, } if args == nil { args = &FrontendArgs{} } args.InstanceArgs = setInstanceDefaults(args.InstanceArgs, frontendDefaults) frontendLabels := mergeLabels(f.Labels, pulumi.StringMap{ "frontend": pulumi.String("true"), }) frontendImage := f.FrontendImage project := f.Project region := f.Region serviceName := f.FrontendName accountName := f.newResourceName(serviceName, "account", 28) serviceAccount, err := serviceaccount.NewAccount(ctx, accountName, &serviceaccount.AccountArgs{ AccountId: pulumi.String(accountName), DisplayName: pulumi.String(fmt.Sprintf("Frontend service account (%s)", serviceName)), Project: pulumi.String(project), }) if err != nil { return nil, nil, fmt.Errorf("failed to create frontend service account: %w", err) } ctx.Export("cloud_run_service_frontend_account_id", serviceAccount.ID()) volumes, volumeMounts, err := f.setupInstanceSecrets(ctx, serviceName, args.Secrets, serviceAccount, frontendLabels, args.InstanceArgs) if err != nil { return nil, nil, fmt.Errorf("failed to setup instance secrets: %w", err) } frontendServiceName := f.newResourceName(serviceName, "service", 100) frontendService, err := cloudrunv2.NewService(ctx, frontendServiceName, &cloudrunv2.ServiceArgs{ Name: pulumi.String(frontendServiceName), Ingress: pulumi.String("INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"), Description: pulumi.String(fmt.Sprintf("Serverless instance (%s)", serviceName)), Location: pulumi.String(region), Project: pulumi.String(project), Labels: frontendLabels, Template: &cloudrunv2.ServiceTemplateArgs{ Scaling: &cloudrunv2.ServiceTemplateScalingArgs{ MaxInstanceCount: pulumi.Int(args.MaxInstanceCount), }, Containers: cloudrunv2.ServiceTemplateContainerArray{ &cloudrunv2.ServiceTemplateContainerArgs{ Image: frontendImage, Resources: &cloudrunv2.ServiceTemplateContainerResourcesArgs{ Limits: args.ResourceLimits, }, Ports: cloudrunv2.ServiceTemplateContainerPortsArgs{ ContainerPort: pulumi.Int(args.ContainerPort), }, Envs: newFrontendEnvVars(args, backendURL), StartupProbe: &cloudrunv2.ServiceTemplateContainerStartupProbeArgs{ TcpSocket: &cloudrunv2.ServiceTemplateContainerStartupProbeTcpSocketArgs{ Port: pulumi.Int(args.ContainerPort), }, InitialDelaySeconds: pulumi.Int(args.StartupProbe.InitialDelaySeconds), PeriodSeconds: pulumi.Int(args.StartupProbe.PeriodSeconds), TimeoutSeconds: pulumi.Int(args.StartupProbe.TimeoutSeconds), FailureThreshold: pulumi.Int(args.StartupProbe.FailureThreshold), }, LivenessProbe: &cloudrunv2.ServiceTemplateContainerLivenessProbeArgs{ HttpGet: &cloudrunv2.ServiceTemplateContainerLivenessProbeHttpGetArgs{ Path: pulumi.String(fmt.Sprintf("/%s", args.LivenessProbe.Path)), Port: pulumi.Int(args.ContainerPort), }, InitialDelaySeconds: pulumi.Int(args.LivenessProbe.InitialDelaySeconds), PeriodSeconds: pulumi.Int(args.LivenessProbe.PeriodSeconds), TimeoutSeconds: pulumi.Int(args.LivenessProbe.TimeoutSeconds), FailureThreshold: pulumi.Int(args.LivenessProbe.FailureThreshold), }, VolumeMounts: volumeMounts, }, }, ServiceAccount: serviceAccount.Email, Volumes: volumes, }, DeletionProtection: pulumi.Bool(args.DeletionProtection), }) if err != nil { return nil, nil, fmt.Errorf("failed to create frontend Cloud Run service: %w", err) } ctx.Export("cloud_run_service_frontend_id", frontendService.ID()) ctx.Export("cloud_run_service_frontend_uri", frontendService.Uri) return frontendService, serviceAccount, nil } func newFrontendEnvVars(args *FrontendArgs, backendURL pulumi.StringOutput) cloudrunv2.ServiceTemplateContainerEnvArray { envVars := cloudrunv2.ServiceTemplateContainerEnvArray{ cloudrunv2.ServiceTemplateContainerEnvArgs{ Name: pulumi.String("DOTENV_CONFIG_PATH"), Value: pulumi.String(fmt.Sprintf("%s%s", args.SecretConfigFilePath, args.SecretConfigFileName)), }, cloudrunv2.ServiceTemplateContainerEnvArgs{ Name: pulumi.String("BACKEND_API_URL"), Value: backendURL, }, } for enVarName, envVarValue := range args.EnvVars { envVars = append(envVars, cloudrunv2.ServiceTemplateContainerEnvArgs{ Name: pulumi.String(enVarName), Value: pulumi.String(envVarValue), }) } return envVars } func newBackendEnvVars(args *BackendArgs) cloudrunv2.ServiceTemplateContainerEnvArray { envVars := cloudrunv2.ServiceTemplateContainerEnvArray{ cloudrunv2.ServiceTemplateContainerEnvArgs{ Name: pulumi.String("DOTENV_CONFIG_PATH"), Value: pulumi.String(fmt.Sprintf("%s%s", args.SecretConfigFilePath, args.SecretConfigFileName)), }, } for enVarName, envVarValue := range args.EnvVars { envVars = append(envVars, cloudrunv2.ServiceTemplateContainerEnvArgs{ Name: pulumi.String(enVarName), Value: pulumi.String(envVarValue), }) } return envVars } func newSecretVolume(secret *SecretVolumeArgs) *cloudrunv2.ServiceTemplateVolumeArgs { newVar := &cloudrunv2.ServiceTemplateVolumeArgs{ Name: pulumi.String(secret.Name), Secret: &cloudrunv2.ServiceTemplateVolumeSecretArgs{ Secret: secret.SecretID, Items: cloudrunv2.ServiceTemplateVolumeSecretItemArray{ &cloudrunv2.ServiceTemplateVolumeSecretItemArgs{ Path: pulumi.String(func() string { if secret.SecretName != "" { return secret.SecretName } return ".env" }()), Version: secret.Version, Mode: pulumi.IntPtr(0400), }, }, }, } return newVar }
// Package config provides an environment config helper package config import ( "fmt" "log" "github.com/kelseyhightower/envconfig" ) // Config allows setting the fullstack via environment variables type Config struct { GCPProject string `envconfig:"GCP_PROJECT" required:"true"` GCPRegion string `envconfig:"GCP_REGION" required:"true"` BackendImage string `envconfig:"BACKEND_IMAGE" required:"true"` FrontendImage string `envconfig:"FRONTEND_IMAGE" required:"true"` DomainURL string `envconfig:"DOMAIN_URL" required:"true"` EnableCloudArmor bool `envconfig:"ENABLE_CLOUD_ARMOR" default:"false"` ClientIPAllowlist []string `envconfig:"CLIENT_IP_ALLOWLIST" default:""` EnablePrivateTrafficOnly bool `envconfig:"ENABLE_PRIVATE_TRAFFIC_ONLY" default:"false"` } // LoadConfig loads configuration from environment variables // All environment variables are required and will cause an error if not set func LoadConfig() (*Config, error) { var config Config err := envconfig.Process("", &config) if err != nil { return nil, fmt.Errorf("failed to load configuration from environment variables: %w", err) } log.Printf("Configuration loaded successfully:") log.Printf(" GCP Project: %s", config.GCPProject) log.Printf(" GCP Region: %s", config.GCPRegion) log.Printf(" Backend Image: %s", config.BackendImage) log.Printf(" Frontend Image: %s", config.FrontendImage) log.Printf(" Domain URL: %s", config.DomainURL) log.Printf(" Enable Cloud Armor: %t", config.EnableCloudArmor) log.Printf(" Client IP Allowlist: %v", config.ClientIPAllowlist) log.Printf(" Enable Private Traffic Only: %t", config.EnablePrivateTrafficOnly) return &config, nil }
// Package gcp provides Google Cloud Platform infrastructure components for fullstack applications. package gcp import ( "fmt" "log" "math" "strings" apigateway "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/apigateway" cloudrunv2 "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/dns" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/redis" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/vpcaccess" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) // FullStack represents a complete fullstack application infrastructure on Google Cloud Platform. type FullStack struct { pulumi.ResourceState Project string Region string BackendName string BackendImage pulumi.StringOutput FrontendName string FrontendImage pulumi.StringOutput Labels map[string]string name string gatewayEnabled bool backendService *cloudrunv2.Service backendAccount *serviceaccount.Account frontendService *cloudrunv2.Service frontendAccount *serviceaccount.Account // IAM members for API Gateway invoker permissions gatewayServiceAccount *serviceaccount.Account backendGatewayIamMember *cloudrunv2.ServiceIamMember frontendGatewayIamMember *cloudrunv2.ServiceIamMember // Network infrastructure apiGateway *apigateway.Gateway apiConfig *apigateway.ApiConfig // The NEG used when API Gateway is enabled apiGatewayNeg *compute.RegionNetworkEndpointGroup // The NEGs used when API Gateway is disabled backendNeg *compute.RegionNetworkEndpointGroup frontendNeg *compute.RegionNetworkEndpointGroup globalForwardingRule *compute.GlobalForwardingRule regionalForwardingRule *compute.ForwardingRule certificate *compute.ManagedSslCertificate dnsRecord *dns.RecordSet urlMap *compute.URLMap // Project-level IAM roles bound to the backend service account backendProjectIamMembers []*projects.IAMMember // Cache infrastructure redisInstance *redis.Instance vpcConnector *vpcaccess.Connector cacheFirewall *compute.Firewall cacheCredentialsSecret *secretmanager.SecretVersion } // NewFullStack creates a new FullStack instance with the provided configuration. func NewFullStack(ctx *pulumi.Context, name string, args *FullStackArgs, opts ...pulumi.ResourceOption) (*FullStack, error) { // Set default values for BackendName and FrontendName if not provided backendName := args.BackendName if backendName == "" { backendName = "backend" } frontendName := args.FrontendName if frontendName == "" { frontendName = "frontend" } fullStack := &FullStack{ Project: args.Project, Region: args.Region, BackendImage: args.BackendImage.ToStringOutput(), FrontendImage: args.FrontendImage.ToStringOutput(), BackendName: backendName, FrontendName: frontendName, Labels: args.Labels, name: name, gatewayEnabled: args.Network != nil && args.Network.APIGateway != nil && !args.Network.APIGateway.Disabled, } err := ctx.RegisterComponentResource("pulumi-fullstack:gcp:FullStack", name, fullStack, opts...) if err != nil { return nil, fmt.Errorf("failed to register component resource: %w", err) } // proceed to provision err = fullStack.deploy(ctx, args) if err != nil { return nil, fmt.Errorf("failed to deploy full stack: %w", err) } err = ctx.RegisterResourceOutputs(fullStack, pulumi.Map{}) if err != nil { return nil, fmt.Errorf("failed to register resource outputs: %w", err) } return fullStack, nil } func (f *FullStack) deploy(ctx *pulumi.Context, args *FullStackArgs) error { if args.Backend != nil && args.Backend.CacheInstance != nil { // Deploy cache companion for backend err := f.deployCache(ctx, args.Backend.CacheInstance) if err != nil { return fmt.Errorf("failed to deploy cache: %w", err) } } backendService, backendAcccount, err := f.deployBackendCloudRunInstance(ctx, args.Backend) if err != nil { return fmt.Errorf("failed to deploy backend Cloud Run: %w", err) } f.backendService = backendService f.backendAccount = backendAcccount frontendService, frontendAccount, err := f.deployFrontendCloudRunInstance(ctx, args.Frontend, backendService.Uri) if err != nil { return fmt.Errorf("failed to deploy frontend Cloud Run: %w", err) } f.frontendService = frontendService f.frontendAccount = frontendAccount var apiGateway *apigateway.Gateway var gatewayArgs *APIGatewayArgs if f.gatewayEnabled { // Deploy API Gateway if enabled gatewayArgs = applyDefaultGatewayArgs(args.Network.APIGateway, backendService.Uri, frontendService.Uri) apiGateway, err = f.deployAPIGateway(ctx, gatewayArgs) if err != nil { return fmt.Errorf("failed to deploy API Gateway: %w", err) } } else { err = f.createCloudRunInstancesIAM(ctx, frontendService, backendService) if err != nil { return fmt.Errorf("failed to create Cloud Run IAM: %w", err) } } // create an external load balancer and point to a serverless NEG (API gateway or Cloud run) err = f.deployExternalLoadBalancer(ctx, args.Network, apiGateway) if err != nil { return fmt.Errorf("failed to deploy external load balancer: %w", err) } return nil } // createCloudRunInstancesIAM creates IAM members to allow unauthenticated access to Cloud Run instances func (f *FullStack) createCloudRunInstancesIAM(ctx *pulumi.Context, frontendService, backendService *cloudrunv2.Service) error { if err := ctx.Log.Info(fmt.Sprintf("Routing traffic to Cloud Run instances: %v and %v", frontendService.Uri, backendService.Uri), nil); err != nil { log.Println("failed to log routing details with Pulumi context: %w", err) } // If no gateway enabled, traffic goes directly to the cloud run instances. yehaaw! _, err := cloudrunv2.NewServiceIamMember(ctx, fmt.Sprintf("%s-allow-unauthenticated", f.FrontendName), &cloudrunv2.ServiceIamMemberArgs{ Name: frontendService.Name, Project: pulumi.String(f.Project), Location: pulumi.String(f.Region), Role: pulumi.String("roles/run.invoker"), Member: pulumi.Sprintf("allUsers"), }) if err != nil { return fmt.Errorf("failed to grant frontend invoker: %w", err) } _, err = cloudrunv2.NewServiceIamMember(ctx, fmt.Sprintf("%s-allow-unauthenticated", f.BackendName), &cloudrunv2.ServiceIamMemberArgs{ Name: backendService.Name, Project: pulumi.String(f.Project), Location: pulumi.String(f.Region), Role: pulumi.String("roles/run.invoker"), Member: pulumi.Sprintf("allUsers"), }) if err != nil { return fmt.Errorf("failed to grant backend invoker: %w", err) } // _, err = cloudrunv2.NewServiceIamMember(ctx, fmt.Sprintf("%s-%s-invoker", f.BackendName, f.FrontendName), &cloudrunv2.ServiceIamMemberArgs{ // Name: backendService.Name, // Project: pulumi.String(f.Project), // Location: pulumi.String(f.Region), // Role: pulumi.String("roles/run.invoker"), // Member: pulumi.Sprintf("serviceAccount:%s", frontendAccount.Email), // }) // if err != nil { // return err // } return nil } func (f *FullStack) newResourceName(serviceName, resourceType string, maxLength int) string { var resourceName string if resourceType == "" { resourceName = fmt.Sprintf("%s-%s", f.name, serviceName) } else { resourceName = fmt.Sprintf("%s-%s-%s", f.name, serviceName, resourceType) } if len(resourceName) <= maxLength { return resourceName } surplus := len(resourceName) - maxLength // Calculate how much to truncate from each part var prefixSurplus, serviceSurplus, typeSurplus int if resourceType == "" { // Only two parts to truncate prefixSurplus = int(math.Ceil(float64(surplus) / 2)) serviceSurplus = surplus - prefixSurplus typeSurplus = 0 } else { prefixSurplus = int(math.Ceil(float64(surplus) / 3)) serviceSurplus = int(math.Ceil(float64(surplus-prefixSurplus) / 2)) typeSurplus = surplus - prefixSurplus - serviceSurplus } // Truncate each part, ensuring we don't truncate more than the part's length // and we keep at least one character to avoid leading dashes var shortPrefix string if prefixSurplus < len(f.name) { shortPrefix = f.name[:len(f.name)-prefixSurplus] } else { shortPrefix = f.name[:1] } var shortServiceName string if serviceSurplus < len(serviceName) { shortServiceName = serviceName[:len(serviceName)-serviceSurplus] } else { shortServiceName = serviceName[:1] } if resourceType == "" { resourceName = fmt.Sprintf("%s-%s", strings.TrimSuffix(shortPrefix, "-"), strings.TrimSuffix(shortServiceName, "-"), ) } else { var shortResourceType string if typeSurplus < len(resourceType) { shortResourceType = resourceType[:len(resourceType)-typeSurplus] } else { shortResourceType = resourceType[:1] } resourceName = fmt.Sprintf("%s-%s-%s", strings.TrimSuffix(shortPrefix, "-"), strings.TrimSuffix(shortServiceName, "-"), strings.TrimSuffix(shortResourceType, "-"), ) } return resourceName } // GetBackendService returns the backend Cloud Run service. func (f *FullStack) GetBackendService() *cloudrunv2.Service { return f.backendService } // GetFrontendService returns the frontend Cloud Run service. func (f *FullStack) GetFrontendService() *cloudrunv2.Service { return f.frontendService } // GetAPIGateway returns the API Gateway instance. func (f *FullStack) GetAPIGateway() *apigateway.Gateway { return f.apiGateway } // GetAPIConfig returns the API Gateway configuration. func (f *FullStack) GetAPIConfig() *apigateway.ApiConfig { return f.apiConfig } // GetBackendGatewayIamMember returns the backend service IAM member for API Gateway invoker permissions. func (f *FullStack) GetBackendGatewayIamMember() *cloudrunv2.ServiceIamMember { return f.backendGatewayIamMember } // GetFrontendGatewayIamMember returns the frontend service IAM member for API Gateway invoker permissions. func (f *FullStack) GetFrontendGatewayIamMember() *cloudrunv2.ServiceIamMember { return f.frontendGatewayIamMember } // GetCertificate returns the managed SSL certificate for the domain. func (f *FullStack) GetCertificate() *compute.ManagedSslCertificate { return f.certificate } // GetGlobalForwardingRule returns the global forwarding rule for the load balancer. func (f *FullStack) GetGlobalForwardingRule() *compute.GlobalForwardingRule { return f.globalForwardingRule } // GetRegionalForwardingRule returns the regional forwarding rule for the load balancer when regional entrypoint is enabled. func (f *FullStack) GetRegionalForwardingRule() *compute.ForwardingRule { return f.regionalForwardingRule } // LookupDNSZone finds the appropriate DNS managed zone for the given domain in the current project func (f *FullStack) LookupDNSZone(ctx *pulumi.Context, domainURL string) (string, error) { return f.lookupDNSZone(ctx, domainURL) } // GetDNSRecord returns the DNS record created for the load balancer func (f *FullStack) GetDNSRecord() *dns.RecordSet { return f.dnsRecord } // GetBackendAccount returns the backend service account. func (f *FullStack) GetBackendAccount() *serviceaccount.Account { return f.backendAccount } // GetFrontendAccount returns the frontend service account. func (f *FullStack) GetFrontendAccount() *serviceaccount.Account { return f.frontendAccount } // GetGatewayServiceAccount returns the API Gateway service account. func (f *FullStack) GetGatewayServiceAccount() *serviceaccount.Account { return f.gatewayServiceAccount } // GetBackendNEG returns the region network endpoint group for the backend service. func (f *FullStack) GetBackendNEG() *compute.RegionNetworkEndpointGroup { return f.backendNeg } // GetFrontendNEG returns the region network endpoint group for the frontend service. func (f *FullStack) GetFrontendNEG() *compute.RegionNetworkEndpointGroup { return f.frontendNeg } // GetGatewayNEG returns the region network endpoint group for the API Gateway. func (f *FullStack) GetGatewayNEG() *compute.RegionNetworkEndpointGroup { return f.apiGatewayNeg } // GetURLMap returns the URL map for the load balancer. func (f *FullStack) GetURLMap() *compute.URLMap { return f.urlMap } // GetBackendProjectIamMembers returns project-level IAM members bound to the backend service account. func (f *FullStack) GetBackendProjectIamMembers() []*projects.IAMMember { return f.backendProjectIamMembers } // GetRedisInstance returns the Redis cache instance. func (f *FullStack) GetRedisInstance() *redis.Instance { return f.redisInstance } // GetVPCConnector returns the VPC access connector for cache connectivity. func (f *FullStack) GetVPCConnector() *vpcaccess.Connector { return f.vpcConnector } // GetCacheFirewall returns the firewall rule for cache connectivity. func (f *FullStack) GetCacheFirewall() *compute.Firewall { return f.cacheFirewall } // GetCacheSecretVersion returns the secret version containing Redis credentials. func (f *FullStack) GetCacheSecretVersion() *secretmanager.SecretVersion { return f.cacheCredentialsSecret }
package gcp import "github.com/pulumi/pulumi/sdk/v3/go/pulumi" // mergeLabels adds custom to default labels. func mergeLabels(defaultLabels map[string]string, additionalLabels pulumi.StringMap) pulumi.StringMap { merged := make(pulumi.StringMap) for k, v := range defaultLabels { merged[k] = pulumi.String(v) } // Add additional labels (these will override any conflicting keys) for k, v := range additionalLabels { merged[k] = v } return merged }
package gcp import ( "fmt" "log" "strings" apigateway "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/apigateway" compute "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/dns" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) // deployExternalLoadBalancer sets up a global classic Application Load Balancer // in front of the Run Service with the following feats: // // - HTTPS by default with GCP managed certificate // - HTTP forward & redirect to HTTPs // - Optional API Gateway integration for backend traffic wrangling // // See: // https://cloud.google.com/load-balancing/docs/https/setting-up-https-serverless // https://cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts#and // https://cloud.google.com/load-balancing/docs/https#global-classic-connections // https://cloud.google.com/api-gateway/docs/gateway-serverless-neg func (f *FullStack) deployExternalLoadBalancer(ctx *pulumi.Context, args *NetworkArgs, apiGateway *apigateway.Gateway) error { endpointName := "gcp-lb" var cloudArmorPolicy *compute.SecurityPolicy var err error if args.EnableCloudArmor { cloudArmorPolicy, err = f.newCloudArmorPolicy(ctx, endpointName, args) if err != nil { return fmt.Errorf("failed to create Cloud Armor policy: %w", err) } } if args.EnableIAP { // identity platform can't be fully enabled programatically yet. // make sure to enable in marketplace console. // // See: // https://issuetracker.google.com/issues/194945691?pli=1 // https://stackoverflow.com/questions/67778417/enable-google-cloud-identity-platform-programmatically-no-ui identityPlatformName := f.newResourceName(endpointName, "cloudidentity", 100) _, err := projects.NewService(ctx, identityPlatformName, &projects.ServiceArgs{ Project: pulumi.String(f.Project), Service: pulumi.String("cloudidentity.googleapis.com"), }) if err != nil { return fmt.Errorf("failed to enable cloudidentity API: %w", err) } idToolkitName := f.newResourceName(endpointName, "idtoolkit", 100) _, err = projects.NewService(ctx, idToolkitName, &projects.ServiceArgs{ Project: pulumi.String(f.Project), Service: pulumi.String("identitytoolkit.googleapis.com"), }) if err != nil { return fmt.Errorf("failed to enable identitytoolkit API: %w", err) } // Enabling IAP requires Google project to be under an organization. // This is a requirement for OAuth Brands which are created as Internal // by default. To allow for external users it needs to be set to Public // via the UI. // An organization can be created via either Cloud Identity or Workspaces (GSuite). // If using Cloud Identity, Google will require you to verify the domain provided // with TXT record. // // See: // https://cloud.google.com/iap/docs/programmatic-oauth-clients#branding // https://support.google.com/cloud/answer/10311615#user-type&zippy=%2Cinternal // https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy#organizations // iapBrandName := f.newResourceName(serviceName, "iap-auth", 100) // _, err = projects.NewService(ctx, iapBrandName, &projects.ServiceArgs{ // Project: pulumi.String(f.Project), // Service: pulumi.String("iap.googleapis.com"), // }) // if err != nil { // return err // } // _, err = iap.NewBrand(ctx, iapBrandName, &iap.BrandArgs{ // SupportEmail: pulumi.String(args.IAPSupportEmail), // ApplicationTitle: pulumi.String("Cloud IAP protected Application"), // Project: projectService.Project, // }) // if err != nil { // return err // } } // Create NEG for either Cloud Run or API Gateway lbRouteURLMap, err := f.setupTrafficRouterToUpstreamNEG(ctx, cloudArmorPolicy, endpointName, args.DomainURL, args.ProxyNetworkName, apiGateway) if err != nil { return fmt.Errorf("failed to setup traffic router: %w", err) } err = f.newHTTPSProxy(ctx, endpointName, args.DomainURL, args.EnablePrivateTrafficOnly, args.EnableGlobalEntrypoint, lbRouteURLMap) if err != nil { return fmt.Errorf("failed to create HTTPS proxy: %w", err) } return nil } func (f *FullStack) newHTTPSProxy(ctx *pulumi.Context, serviceName, domainURL string, privateTraffic bool, enableGlobalEntrypoint bool, backendURLMap *compute.URLMap) error { tlsCertName := f.newResourceName(serviceName, "tls-cert", 100) certificate, err := compute.NewManagedSslCertificate(ctx, tlsCertName, &compute.ManagedSslCertificateArgs{ Description: pulumi.String(fmt.Sprintf("TLS cert for %s", serviceName)), Project: pulumi.String(f.Project), Managed: &compute.ManagedSslCertificateManagedArgs{ Domains: pulumi.StringArray{ pulumi.String(domainURL), }, }, }) if err != nil { return fmt.Errorf("failed to create managed SSL certificate: %w", err) } ctx.Export("load_balancer_https_certificate_id", certificate.ID()) ctx.Export("load_balancer_https_certificate_uri", certificate.SelfLink) f.certificate = certificate httpsProxyName := f.newResourceName(serviceName, "https-proxy", 100) httpsProxy, err := compute.NewTargetHttpsProxy(ctx, httpsProxyName, &compute.TargetHttpsProxyArgs{ Description: pulumi.String(fmt.Sprintf("proxy to LB traffic for %s", serviceName)), Project: pulumi.String(f.Project), UrlMap: backendURLMap.SelfLink, SslCertificates: pulumi.StringArray{ certificate.SelfLink, }, }) if err != nil { return fmt.Errorf("failed to create target HTTPS proxy: %w", err) } ctx.Export("load_balancer_https_proxy_id", httpsProxy.ID()) ctx.Export("load_balancer_https_proxy_uri", httpsProxy.SelfLink) if !privateTraffic { var lbIPAddress pulumi.StringOutput if enableGlobalEntrypoint { lbIPAddress, err = f.createGlobalInternetEntrypoint(ctx, serviceName, httpsProxy) } else { lbIPAddress, err = f.createRegionalInternetEntrypoint(ctx, serviceName, httpsProxy) } if err != nil { return fmt.Errorf("failed to create internet entrypoint: %w", err) } // Create DNS record for the LB IP address dnsRecord, dnsErr := f.createDNSRecord(ctx, serviceName, domainURL, lbIPAddress) if dnsErr != nil { return fmt.Errorf("failed to create DNS record: %w", dnsErr) } f.dnsRecord = dnsRecord } return nil } func (f *FullStack) setupTrafficRouterToUpstreamNEG(ctx *pulumi.Context, policy *compute.SecurityPolicy, serviceName, domainURL, network string, apiGateway *apigateway.Gateway) (*compute.URLMap, error) { // create proxy-only subnet required by Cloud Run to get traffic from the LB // See: // https://cloud.google.com/load-balancing/docs/https#proxy-only-subnet trafficNetwork := network if trafficNetwork == "" { trafficNetwork = "default" } proxySubnetName := f.newResourceName(serviceName, "proxy-subnet", 100) subnet, err := compute.NewSubnetwork(ctx, proxySubnetName, &compute.SubnetworkArgs{ Name: pulumi.String(proxySubnetName), Description: pulumi.String(fmt.Sprintf("proxy-only subnet for %s traffic", serviceName)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), Purpose: pulumi.String("REGIONAL_MANAGED_PROXY"), Network: pulumi.String(trafficNetwork), // Extended subnetworks in auto subnet mode networks cannot overlap with 10.128.0.0/9 IpCidrRange: pulumi.String("10.127.0.0/24"), Role: pulumi.String("ACTIVE"), }) if err != nil { return nil, fmt.Errorf("failed to create proxy-only subnet: %w", err) } ctx.Export("load_balancer_proxy_subnet_id", subnet.ID()) ctx.Export("load_balancer_proxy_subnet_uri", subnet.SelfLink) var urlMap *compute.URLMap if f.gatewayEnabled { urlMap, err = f.routeTrafficToGateway(ctx, policy, serviceName, domainURL, apiGateway) if err != nil { return nil, fmt.Errorf("failed to route traffic to API Gateway: %w", err) } } else { urlMap, err = f.routeTrafficToCloudRunInstances(ctx, policy, serviceName, domainURL) if err != nil { return nil, fmt.Errorf("failed to route traffic to Cloud Run: %w", err) } } ctx.Export("load_balancer_url_map_id", urlMap.ID()) ctx.Export("load_balancer_url_map_uri", urlMap.SelfLink) f.urlMap = urlMap return urlMap, nil } // routeTrafficToGateway creates a Gateway NEG and routes traffic to it // and returns the URL map. func (f *FullStack) routeTrafficToGateway(ctx *pulumi.Context, policy *compute.SecurityPolicy, serviceName, _ string, apiGateway *apigateway.Gateway) (*compute.URLMap, error) { // Create NEG for API Gateway lbGatewayBackendService, err := f.createGatewayNEG(ctx, policy, serviceName, apiGateway) if err != nil { return nil, fmt.Errorf("failed to create API Gateway NEG: %w", err) } urlMapName := f.newResourceName(serviceName, "url-map", 100) // Create URL map for Gateway NEG urlMap, err := compute.NewURLMap(ctx, urlMapName, &compute.URLMapArgs{ Description: pulumi.String(fmt.Sprintf("URL map to LB traffic for %s", serviceName)), Project: pulumi.String(f.Project), // All traffic is deferred to the Gateway NEG DefaultService: lbGatewayBackendService.SelfLink, // TODO set host rules to match DNS }) if err != nil { return nil, fmt.Errorf("failed to create URL map for Gateway: %w", err) } return urlMap, nil } // createGatewayNEG creates a Network Endpoint Group (NEG) for API Gateway integration // and returns the associated backend service. func (f *FullStack) createGatewayNEG(ctx *pulumi.Context, policy *compute.SecurityPolicy, serviceName string, apiGateway *apigateway.Gateway) (*compute.BackendService, error) { // This feature is currently in preview. The NEG gets to fail attached to the API Gateway. // See: // - https://discuss.google.dev/t/serverless-neg-and-api-gateway/189045 // - https://discuss.google.dev/t/cloud-run-accessed-via-serverless-neg-with-url-mask-returns-404/172725 gatewayNegName := f.newResourceName(serviceName, "gateway-neg", 100) neg, err := compute.NewRegionNetworkEndpointGroup(ctx, gatewayNegName, &compute.RegionNetworkEndpointGroupArgs{ Description: pulumi.String(fmt.Sprintf("NEG to route LB traffic to API Gateway for %s", serviceName)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), NetworkEndpointType: pulumi.String("SERVERLESS"), ServerlessDeployment: &compute.RegionNetworkEndpointGroupServerlessDeploymentArgs{ Platform: pulumi.String("apigateway.googleapis.com"), Resource: apiGateway.GatewayId, // Gateway NEG can also be configured with a URL mask // See: // - https://cloud.google.com/load-balancing/docs/https/setting-up-https-serverless#using-url-mask // UrlMask: pulumi.String("davidmontoyago.path2prod.dev/<gateway>/my-gateway-id"), }, }) if err != nil { return nil, fmt.Errorf("failed to create Gateway NEG: %w", err) } f.apiGatewayNeg = neg ctx.Export("load_balancer_gateway_network_endpoint_group_id", neg.ID()) ctx.Export("load_balancer_gateway_network_endpoint_group_uri", neg.SelfLink) lbGatewayServiceArgs := &compute.BackendServiceArgs{ Description: pulumi.String(fmt.Sprintf("service backend for %s", serviceName)), Project: pulumi.String(f.Project), LoadBalancingScheme: pulumi.String("EXTERNAL"), Backends: compute.BackendServiceBackendArray{ &compute.BackendServiceBackendArgs{ // Point the LB backend to the Gateway NEG Group: f.apiGatewayNeg.SelfLink, }, }, } // Attach Cloud Armor policy if enabled if policy != nil { lbGatewayServiceArgs.SecurityPolicy = policy.SelfLink } // Create the LB's backend service for Gateway NEG backendServiceName := f.newResourceName(serviceName, "gateway-backend-service", 100) lbGatewayBackendService, err := compute.NewBackendService(ctx, backendServiceName, lbGatewayServiceArgs) if err != nil { return nil, fmt.Errorf("failed to create Gateway backend service: %w", err) } ctx.Export("load_balancer_gateway_backend_service_id", lbGatewayBackendService.ID()) ctx.Export("load_balancer_gateway_backend_service_uri", lbGatewayBackendService.SelfLink) return lbGatewayBackendService, nil } // createCloudRunNEGs creates Network Endpoint Groups (NEGs) for Cloud Run instances // and returns the associated backend and frontend services. func (f *FullStack) createCloudRunNEGs(ctx *pulumi.Context, policy *compute.SecurityPolicy, serviceName string) (*compute.BackendService, *compute.BackendService, error) { // No Gateway. Create NEGs for backend and frontendCloud Run instances cloudrunBackendNegName := f.newResourceName(serviceName, "backend-cloudrun-neg", 100) backendNeg, err := compute.NewRegionNetworkEndpointGroup(ctx, cloudrunBackendNegName, &compute.RegionNetworkEndpointGroupArgs{ Description: pulumi.String(fmt.Sprintf("NEG to route LB traffic to %s", serviceName)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), NetworkEndpointType: pulumi.String("SERVERLESS"), CloudRun: &compute.RegionNetworkEndpointGroupCloudRunArgs{ Service: f.backendService.Name, }, }) if err != nil { return nil, nil, fmt.Errorf("failed to create backend Cloud Run NEG: %w", err) } f.backendNeg = backendNeg ctx.Export("load_balancer_backend_network_endpoint_group_id", backendNeg.ID()) ctx.Export("load_balancer_backend_network_endpoint_group_uri", backendNeg.SelfLink) cloudrunFrontendNegName := f.newResourceName(serviceName, "frontend-cloudrun-neg", 100) frontendNeg, err := compute.NewRegionNetworkEndpointGroup(ctx, cloudrunFrontendNegName, &compute.RegionNetworkEndpointGroupArgs{ Description: pulumi.String(fmt.Sprintf("NEG to route LB traffic to %s", serviceName)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), NetworkEndpointType: pulumi.String("SERVERLESS"), CloudRun: &compute.RegionNetworkEndpointGroupCloudRunArgs{ Service: f.frontendService.Name, }, }) if err != nil { return nil, nil, fmt.Errorf("failed to create frontend Cloud Run NEG: %w", err) } f.frontendNeg = frontendNeg ctx.Export("load_balancer_frontend_network_endpoint_group_id", frontendNeg.ID()) ctx.Export("load_balancer_frontend_network_endpoint_group_uri", frontendNeg.SelfLink) lbBackendServiceArgs := &compute.BackendServiceArgs{ Description: pulumi.String(fmt.Sprintf("service backend for %s", serviceName)), Project: pulumi.String(f.Project), LoadBalancingScheme: pulumi.String("EXTERNAL"), Backends: compute.BackendServiceBackendArray{ &compute.BackendServiceBackendArgs{ // Point the LB backend to the Gateway NEG Group: f.backendNeg.SelfLink, }, }, } lbFrontendServiceArgs := &compute.BackendServiceArgs{ Description: pulumi.String(fmt.Sprintf("service backend for %s", serviceName)), Project: pulumi.String(f.Project), LoadBalancingScheme: pulumi.String("EXTERNAL"), Backends: compute.BackendServiceBackendArray{ &compute.BackendServiceBackendArgs{ // Point the LB backend to the Gateway NEG Group: f.frontendNeg.SelfLink, }, }, } // Attach Cloud Armor policy if enabled if policy != nil { lbBackendServiceArgs.SecurityPolicy = policy.SelfLink lbFrontendServiceArgs.SecurityPolicy = policy.SelfLink } // Create the LB backends - They'll be attached to the URL map backendServiceName := f.newResourceName(serviceName, "cloudrun-backend-service", 100) backendService, err := compute.NewBackendService(ctx, backendServiceName, lbBackendServiceArgs) if err != nil { return nil, nil, fmt.Errorf("failed to create backend service for NEG: %w", err) } ctx.Export("load_balancer_cloud_run_backend_service_id", backendService.ID()) ctx.Export("load_balancer_cloud_run_backend_service_uri", backendService.SelfLink) frontendServiceName := f.newResourceName(serviceName, "cloudrun-frontend-service", 100) frontendService, err := compute.NewBackendService(ctx, frontendServiceName, lbFrontendServiceArgs) if err != nil { return nil, nil, fmt.Errorf("failed to create frontend service for NEG: %w", err) } ctx.Export("load_balancer_cloud_run_frontend_service_id", frontendService.ID()) ctx.Export("load_balancer_cloud_run_frontend_service_uri", frontendService.SelfLink) return backendService, frontendService, nil } // routeTrafficToCloudRunInstances creates Cloud Run NEGs and URL mapping rules to route traffic to them // and returns the URL map. func (f *FullStack) routeTrafficToCloudRunInstances(ctx *pulumi.Context, policy *compute.SecurityPolicy, serviceName, domainURL string) (*compute.URLMap, error) { // Create NEGs for Cloud Run instances backendService, frontendService, err := f.createCloudRunNEGs(ctx, policy, serviceName) if err != nil { return nil, fmt.Errorf("failed to create Cloud Run NEGs: %w", err) } urlMapName := f.newResourceName(serviceName, "url-map", 100) paths := &compute.URLMapPathMatcherArgs{ Name: pulumi.String("traffic-paths"), DefaultService: backendService.SelfLink, PathRules: compute.URLMapPathMatcherPathRuleArray{ &compute.URLMapPathMatcherPathRuleArgs{ Paths: pulumi.StringArray{ // TODO make me configurable pulumi.String("/api/*"), }, Service: backendService.SelfLink, }, &compute.URLMapPathMatcherPathRuleArgs{ Paths: pulumi.StringArray{ // TODO make me configurable pulumi.String("/*"), }, Service: frontendService.SelfLink, }, }, } urlMap, err := compute.NewURLMap(ctx, urlMapName, &compute.URLMapArgs{ Description: pulumi.String(fmt.Sprintf("URL map to LB traffic for %s", serviceName)), Project: pulumi.String(f.Project), // Default to the backend if no path matches DefaultService: backendService.SelfLink, PathMatchers: compute.URLMapPathMatcherArray{ paths, }, // Host rules (can be customized for your domain) HostRules: compute.URLMapHostRuleArray{ &compute.URLMapHostRuleArgs{ Hosts: pulumi.StringArray{ // Favor domain URL over "*" to avoid host header attacks pulumi.String(domainURL), }, PathMatcher: paths.Name, }, }, }) if err != nil { return nil, fmt.Errorf("failed to create URL map for Cloud Run: %w", err) } return urlMap, nil } // createGlobalInternetEntrypoint creates a global IP address and forwarding rule for external traffic func (f *FullStack) createGlobalInternetEntrypoint(ctx *pulumi.Context, serviceName string, httpsProxy *compute.TargetHttpsProxy) (pulumi.StringOutput, error) { labels := mergeLabels(f.Labels, pulumi.StringMap{ "load_balancer": pulumi.String("true"), }) // reserve an IP address for the LB ipAddressName := f.newResourceName(serviceName, "global-ip", 100) ipAddress, err := compute.NewGlobalAddress(ctx, ipAddressName, &compute.GlobalAddressArgs{ Project: pulumi.String(f.Project), Description: pulumi.String(fmt.Sprintf("IP address for %s", serviceName)), IpVersion: pulumi.String("IPV4"), Labels: labels, }) if err != nil { return pulumi.StringOutput{}, fmt.Errorf("failed to reserve global IP address: %w", err) } ctx.Export("load_balancer_global_address_id", ipAddress.ID()) ctx.Export("load_balancer_global_address_uri", ipAddress.SelfLink) ctx.Export("load_balancer_global_address_ip_address", ipAddress.Address) // https://cloud.google.com/load-balancing/docs/https#forwarding-rule forwardingRuleName := f.newResourceName(serviceName, "https-forwarding", 100) trafficRule, err := compute.NewGlobalForwardingRule(ctx, forwardingRuleName, &compute.GlobalForwardingRuleArgs{ Description: pulumi.String(fmt.Sprintf("HTTPS forwarding rule to LB traffic for %s", serviceName)), Project: pulumi.String(f.Project), PortRange: pulumi.String("443"), LoadBalancingScheme: pulumi.String("EXTERNAL"), Target: httpsProxy.SelfLink, IpAddress: ipAddress.Address, Labels: labels, }) if err != nil { return pulumi.StringOutput{}, fmt.Errorf("failed to create global forwarding rule: %w", err) } ctx.Export("load_balancer_global_forwarding_rule_id", trafficRule.ID()) ctx.Export("load_balancer_global_forwarding_rule_uri", trafficRule.SelfLink) ctx.Export("load_balancer_global_forwarding_rule_ip_address", trafficRule.IpAddress) // Store the forwarding rule in the FullStack struct f.globalForwardingRule = trafficRule return ipAddress.Address, nil } // createRegionalInternetEntrypoint creates a regional IP address and regional forwarding rule // for external traffic (Classic Application Load Balancer in Standard Tier) func (f *FullStack) createRegionalInternetEntrypoint(ctx *pulumi.Context, serviceName string, httpsProxy *compute.TargetHttpsProxy) (pulumi.StringOutput, error) { labels := mergeLabels(f.Labels, pulumi.StringMap{ "load_balancer": pulumi.String("true"), }) // Reserve a regional IP address for the LB (Standard Network Tier) ipAddressName := f.newResourceName(serviceName, "regional-ip", 100) ipAddress, err := compute.NewAddress(ctx, ipAddressName, &compute.AddressArgs{ Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), Description: pulumi.String(fmt.Sprintf("Regional IP address for %s", serviceName)), // Classic ALB with regional FR requires Standard tier NetworkTier: pulumi.StringPtr("STANDARD"), Labels: labels, }) if err != nil { return pulumi.StringOutput{}, fmt.Errorf("failed to reserve regional IP address: %w", err) } ctx.Export("load_balancer_regional_address_id", ipAddress.ID()) ctx.Export("load_balancer_regional_address_uri", ipAddress.SelfLink) ctx.Export("load_balancer_regional_address_ip_address", ipAddress.Address) // Create a regional forwarding rule pointing to the global Target HTTPS Proxy (classic ALB) forwardingRuleName := f.newResourceName(serviceName, "regional-https-forwarding", 100) trafficRule, err := compute.NewForwardingRule(ctx, forwardingRuleName, &compute.ForwardingRuleArgs{ Description: pulumi.String(fmt.Sprintf("HTTPS forwarding rule to LB traffic for %s", serviceName)), Project: pulumi.String(f.Project), Region: pulumi.String(f.Region), PortRange: pulumi.String("443"), LoadBalancingScheme: pulumi.String("EXTERNAL"), Target: httpsProxy.SelfLink, IpAddress: ipAddress.Address, // Classic ALB regional requires Standard tier NetworkTier: pulumi.StringPtr("STANDARD"), Labels: labels, }) if err != nil { return pulumi.StringOutput{}, fmt.Errorf("failed to create regional forwarding rule: %w", err) } ctx.Export("load_balancer_regional_forwarding_rule_id", trafficRule.ID()) ctx.Export("load_balancer_regional_forwarding_rule_uri", trafficRule.SelfLink) ctx.Export("load_balancer_regional_forwarding_rule_ip_address", trafficRule.IpAddress) // Store the forwarding rule in the FullStack struct f.regionalForwardingRule = trafficRule return ipAddress.Address, nil } // lookupDNSZone finds the appropriate DNS managed zone for the given domain func (f *FullStack) lookupDNSZone(ctx *pulumi.Context, domainURL string) (string, error) { // Get all managed zones in the project managedZones, err := dns.GetManagedZones(ctx, &dns.GetManagedZonesArgs{ Project: &f.Project, }) if err != nil { return "", fmt.Errorf("failed to get managed zones: %w", err) } // Find the managed zone that matches our domain var targetZoneName string var targetZoneDNSName string for _, zone := range managedZones.ManagedZones { // Check if the domain URL ends with the zone's DNS name (with or without trailing dot) zoneDNSName := strings.TrimSuffix(zone.DnsName, ".") if strings.HasSuffix(domainURL, zoneDNSName) { // If we find multiple matches, prefer the most specific one (longest DNS name) if targetZoneName == "" || len(zone.DnsName) > len(targetZoneDNSName) { if zone.Name != nil { targetZoneName = *zone.Name } targetZoneDNSName = zone.DnsName } } } if targetZoneName == "" { return "", fmt.Errorf("no managed zone found for domain %s in project %s", domainURL, f.Project) } if err := ctx.Log.Debug(fmt.Sprintf("Found managed zone %s for domain %s", targetZoneName, domainURL), nil); err != nil { log.Printf("failed to log managed zone with Pulumi context: %v", err) } return targetZoneName, nil } // createDNSRecord creates a DNS A record for the given domain and IP address func (f *FullStack) createDNSRecord(ctx *pulumi.Context, serviceName, domainURL string, ipAddress pulumi.StringOutput) (*dns.RecordSet, error) { // Look up the DNS managed zone for the domain managedZoneName, err := f.lookupDNSZone(ctx, domainURL) if err != nil { return nil, err } dnsRecordName := f.newResourceName(serviceName, "dns-record", 100) // Ensure domain URL ends with a trailing dot for DNS compliance dnsName := domainURL if !strings.HasSuffix(dnsName, ".") { dnsName += "." } dnsRecord, err := dns.NewRecordSet(ctx, dnsRecordName, &dns.RecordSetArgs{ ManagedZone: pulumi.String(managedZoneName), Name: pulumi.String(dnsName), Type: pulumi.String("A"), Ttl: pulumi.Int(3600), Rrdatas: pulumi.StringArray{ipAddress}, }) if err != nil { return nil, err } ctx.Export("load_balancer_dns_record_id", dnsRecord.ID()) ctx.Export("load_balancer_dns_record_ip_address", dnsRecord.Rrdatas) return dnsRecord, nil }
package gcp import ( "github.com/getkin/kin-openapi/openapi3" ) // Path translation constants const ( AppendPathToAddress = "APPEND_PATH_TO_ADDRESS" ConstantAddress = "CONSTANT_ADDRESS" ConstantAddressWithPath = "CONSTANT_ADDRESS_WITH_PATH" ) // newOpenAPISpec creates a new OpenAPI 3.0.1 specification for API Gateway // that routes traffic to Cloud Run backend and frontend services. func newOpenAPISpec(backendServiceURI, frontendServiceURI string, configArgs *APIConfigArgs, backendJWTConfig *JWTAuth) *openapi3.T { paths := &openapi3.Paths{} securitySchemes := make(openapi3.SecuritySchemes) // Configure JWT authentication if enabled for backend if backendJWTConfig != nil { // Add JWT security scheme for service-to-service authentication securitySchemes["JWT"] = &openapi3.SecuritySchemeRef{ Value: &openapi3.SecurityScheme{ Type: "http", Scheme: "bearer", BearerFormat: "JWT", Extensions: map[string]interface{}{ "x-google-issuer": backendJWTConfig.Issuer, "x-google-jwks_uri": backendJWTConfig.JwksURI, }, }, } } // Add backend API paths if configArgs != nil && configArgs.Backend != nil && len(configArgs.Backend.APIPaths) > 0 { addPaths(paths, configArgs.Backend.APIPaths, backendServiceURI, createAPIPathItem, backendJWTConfig) } else { // Default backend path if none specified pathItem := createAPIPathItem(backendServiceURI, "/api/v1", AppendPathToAddress) // Apply JWT security if configured if backendJWTConfig != nil { pathItem.Get.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Post.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Put.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Delete.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} // OPTIONS should not require authentication for CORS preflight } paths.Set("/api/v1/{proxy}", pathItem) } // Add frontend API paths if configArgs != nil && configArgs.Frontend != nil && len(configArgs.Frontend.APIPaths) > 0 { addPaths(paths, configArgs.Frontend.APIPaths, frontendServiceURI, createUIPathItem, nil) // Frontend doesn't need JWT auth } else { // Default frontend path if none specified paths.Set("/ui/{proxy}", createUIPathItem(frontendServiceURI, "/ui/v1", AppendPathToAddress)) } spec := &openapi3.T{ OpenAPI: "3.0.1", Info: &openapi3.Info{ Title: "API Gateway for Cloud Run", Description: "API Gateway routing to Cloud Run backend and frontend", Version: "1.0.0", }, Servers: openapi3.Servers{ &openapi3.Server{ URL: "https://{gateway_host}", }, }, Paths: paths, Components: &openapi3.Components{ SecuritySchemes: securitySchemes, }, } // Add CORS configuration if enabled if configArgs != nil && configArgs.EnableCORS { spec.Extensions = make(map[string]interface{}) spec.Extensions["x-google-cors"] = createCORSConfig(configArgs) } return spec } // createUpstreamPath is a function type for creating path items type createUpstreamPath func(serviceURI, upstreamPath, pathTranslation string) *openapi3.PathItem // addPaths creates OpenAPI paths from a list of path configurations func addPaths(paths *openapi3.Paths, pathConfigs []*APIPathArgs, serviceURI string, createPathItem createUpstreamPath, jwtAuthConfig *JWTAuth) { for _, pathConfig := range pathConfigs { // Always match the remaining of the path (/{proxy}) and pass it to the upstream gatewayPath := pathConfig.Path + "/{proxy}" upstreamPath := pathConfig.UpstreamPath // Decide how to translate to upstream var pathTranslation string rewritePath := upstreamPath != "" && upstreamPath != pathConfig.Path if rewritePath { // The gateway and upstream share the same path pathTranslation = AppendPathToAddress } else { // Path rewriting - the upstream chose its own path upstreamPath = pathConfig.UpstreamPath pathTranslation = ConstantAddress } pathItem := createPathItem(serviceURI, upstreamPath, pathTranslation) // Apply JWT security if configured if jwtAuthConfig != nil { pathItem.Get.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Post.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Put.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} pathItem.Delete.Security = &openapi3.SecurityRequirements{{"JWT": []string{}}} // OPTIONS should not require authentication for CORS preflight } paths.Set(gatewayPath, pathItem) } } // createCORSConfig creates the CORS configuration for Google API Gateway func createCORSConfig(configArgs *APIConfigArgs) map[string]interface{} { // Set default CORS values corsAllowedOrigins := configArgs.CORSAllowedOrigins if len(corsAllowedOrigins) == 0 { corsAllowedOrigins = []string{"*"} } corsAllowedMethods := configArgs.CORSAllowedMethods if len(corsAllowedMethods) == 0 { corsAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} } corsAllowedHeaders := configArgs.CORSAllowedHeaders if len(corsAllowedHeaders) == 0 { corsAllowedHeaders = []string{"*"} } return map[string]interface{}{ "allowOrigin": corsAllowedOrigins[0], "allowMethods": corsAllowedMethods[0], "allowHeaders": corsAllowedHeaders[0], "exposeHeaders": "Content-Length", "maxAge": "3600", } } // createAPIPathItem creates a PathItem for API routes with all HTTP methods func createAPIPathItem(backendServiceURI, upstreamPath, pathTranslation string) *openapi3.PathItem { return &openapi3.PathItem{ Get: createAPIOperation("apiProxyGet", "get", backendServiceURI, upstreamPath, pathTranslation), Post: createAPIOperation("apiProxyPost", "post", backendServiceURI, upstreamPath, pathTranslation), Put: createAPIOperation("apiProxyPut", "put", backendServiceURI, upstreamPath, pathTranslation), Delete: createAPIOperation("apiProxyDelete", "delete", backendServiceURI, upstreamPath, pathTranslation), Options: createCORSOperation("apiProxyOptions", backendServiceURI, upstreamPath, pathTranslation), } } // createUIPathItem creates a PathItem for UI routes with GET and OPTIONS methods func createUIPathItem(frontendServiceURI, upstreamPath, pathTranslation string) *openapi3.PathItem { return &openapi3.PathItem{ Get: createUIOperation("uiProxyGet", frontendServiceURI, upstreamPath, pathTranslation), Options: createCORSOperation("uiProxyOptions", frontendServiceURI, upstreamPath, pathTranslation), } } // createAPIOperation creates an operation for API endpoints func createAPIOperation(operationID, method, serviceURI, upstreamPath, pathTranslation string) *openapi3.Operation { operation := &openapi3.Operation{ OperationID: operationID, Parameters: []*openapi3.ParameterRef{ { Value: &openapi3.Parameter{ Name: "proxy", In: "path", Required: true, Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, }, }, }, Responses: openapi3.NewResponses(), Extensions: map[string]interface{}{ "x-google-backend": map[string]interface{}{ "address": serviceURI + upstreamPath, "pathTranslation": pathTranslation, "protocol": "h2", }, }, } // Add request body for POST and PUT operations if method == "post" || method == "put" { operation.RequestBody = &openapi3.RequestBodyRef{ Value: &openapi3.RequestBody{ Required: false, Content: openapi3.NewContentWithJSONSchema(openapi3.NewObjectSchema()), }, } } // Add responses with proper descriptions for v2 compatibility operation.Responses.Set("200", &openapi3.ResponseRef{Value: &openapi3.Response{ Description: stringPtr("Successful response"), Content: openapi3.NewContentWithJSONSchema(openapi3.NewObjectSchema()), }}) operation.Responses.Set("400", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Bad request")}}) operation.Responses.Set("401", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Unauthorized")}}) operation.Responses.Set("403", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Forbidden")}}) operation.Responses.Set("404", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Not found")}}) operation.Responses.Set("500", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Internal server error")}}) // Add default response to catch all other cases operation.Responses.Set("default", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Default response")}}) return operation } // createUIOperation creates an operation for UI endpoints func createUIOperation(operationID, serviceURI, upstreamPath, pathTranslation string) *openapi3.Operation { operation := &openapi3.Operation{ OperationID: operationID, Parameters: []*openapi3.ParameterRef{ { Value: &openapi3.Parameter{ Name: "proxy", In: "path", Required: true, Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, }, }, }, Responses: openapi3.NewResponses(), Extensions: map[string]interface{}{ "x-google-backend": map[string]interface{}{ "address": serviceURI + upstreamPath, "pathTranslation": pathTranslation, "protocol": "h2", }, }, } // Add responses with proper descriptions for v2 compatibility operation.Responses.Set("200", &openapi3.ResponseRef{Value: &openapi3.Response{ Description: stringPtr("Successful response"), Content: openapi3.NewContentWithJSONSchema(openapi3.NewObjectSchema()), }}) operation.Responses.Set("404", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Not found")}}) operation.Responses.Set("default", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Default response")}}) return operation } // createCORSOperation creates an OPTIONS operation for CORS preflight requests func createCORSOperation(operationID, serviceURI, upstreamPath, pathTranslation string) *openapi3.Operation { operation := &openapi3.Operation{ OperationID: operationID, Parameters: []*openapi3.ParameterRef{ { Value: &openapi3.Parameter{ Name: "proxy", In: "path", Required: true, Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, }, }, }, Responses: openapi3.NewResponses(), Extensions: map[string]interface{}{ "x-google-backend": map[string]interface{}{ "address": serviceURI + upstreamPath, "pathTranslation": pathTranslation, "protocol": "h2", }, }, } // Add responses for CORS preflight operation.Responses.Set("200", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("CORS preflight successful")}}) operation.Responses.Set("default", &openapi3.ResponseRef{Value: &openapi3.Response{Description: stringPtr("Default response")}}) return operation } // stringPtr returns a pointer to the given string func stringPtr(s string) *string { return &s }
package gcp import ( "fmt" secretmanager "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/secretmanager" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) func (f *FullStack) newEnvConfigSecret(ctx *pulumi.Context, serviceName string, serviceAccount *serviceaccount.Account, deletionProtection bool, labels pulumi.StringMap, ) (*secretmanager.Secret, error) { secretID := f.newResourceName(serviceName, "config-secret", 100) configSecret, err := secretmanager.NewSecret(ctx, secretID, &secretmanager.SecretArgs{ Labels: labels, Replication: &secretmanager.SecretReplicationArgs{ // With google-managed default encryption Auto: &secretmanager.SecretReplicationAutoArgs{}, }, SecretId: pulumi.String(secretID), DeletionProtection: pulumi.Bool(deletionProtection), }) if err != nil { return nil, fmt.Errorf("failed to create secret: %w", err) } // allow the instance GSA to access the secret secretAccessorName := f.newResourceName(serviceName, "secret-accessor", 100) _, err = secretmanager.NewSecretIamMember(ctx, secretAccessorName, &secretmanager.SecretIamMemberArgs{ Project: pulumi.String(f.Project), SecretId: configSecret.SecretId, Role: pulumi.String("roles/secretmanager.secretAccessor"), Member: pulumi.Sprintf("serviceAccount:%s", serviceAccount.Email), }) if err != nil { return nil, fmt.Errorf("failed to grant secret accessor: %w", err) } // Create initial empty secret version _, versionErr := secretmanager.NewSecretVersion(ctx, fmt.Sprintf("%s-secret-seed", secretID), &secretmanager.SecretVersionArgs{ Secret: configSecret.ID(), SecretData: pulumi.String(fmt.Sprintf("SERVICE_NAME=%s", serviceName)), }, pulumi.IgnoreChanges([]string{"secretData"}), ) if versionErr != nil { return nil, fmt.Errorf("failed to create secret version: %w", versionErr) } return configSecret, nil }
package gcp import ( "fmt" compute "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) // creates a best-practice Cloud Armor security policy. // See: // https://github.com/GoogleCloudPlatform/terraform-google-cloud-armor/blob/9ea03ee3ff0778a087888582e806da7342635d69/main.tf#L445 func (f *FullStack) newCloudArmorPolicy(ctx *pulumi.Context, policyName string, args *NetworkArgs) (*compute.SecurityPolicy, error) { // Every security policy must have a default rule at priority 2147483647 with match condition *. // See: // https://cloud.google.com/armor/docs/waf-rules defaultRules := newDefaultRule() preconfiguredRules := newPreconfiguredRules() rules := make(compute.SecurityPolicyRuleTypeArray, 0, len(defaultRules)+len(preconfiguredRules)) rules = append(rules, defaultRules...) rules = append(rules, preconfiguredRules...) if len(args.ClientIPAllowlist) > 0 { // IP allowlist rule to restrict access to a handful of IPs... not for the enterprise ipAllowlistRules := newIPAllowlistRules(args.ClientIPAllowlist) rules = append(rules, ipAllowlistRules...) } // TODO allow reCAPTCHA // TODO add rate limiting rules // TODO add named IP preconfigured rules cloudArmorPolicyName := f.newResourceName(policyName, "cloudarmor", 100) policy, err := compute.NewSecurityPolicy(ctx, cloudArmorPolicyName, &compute.SecurityPolicyArgs{ Description: pulumi.String(fmt.Sprintf("Cloud Armor security policy for %s", policyName)), Project: pulumi.String(f.Project), Rules: rules, Type: pulumi.String("CLOUD_ARMOR"), }) if err != nil { return nil, fmt.Errorf("failed to create Cloud Armor policy: %w", err) } ctx.Export("cloud_armor_security_policy_id", policy.ID()) ctx.Export("cloud_armor_security_policy_uri", policy.SelfLink) return policy, nil } func newDefaultRule() compute.SecurityPolicyRuleTypeArray { var defaultRules compute.SecurityPolicyRuleTypeArray defaultRules = append(defaultRules, &compute.SecurityPolicyRuleTypeArgs{ Action: pulumi.String("allow"), Description: pulumi.String("Default allow rule"), Priority: pulumi.Int(2147483647), Match: &compute.SecurityPolicyRuleMatchArgs{ VersionedExpr: pulumi.String("SRC_IPS_V1"), Config: &compute.SecurityPolicyRuleMatchConfigArgs{ SrcIpRanges: pulumi.StringArray{ pulumi.String("*"), }, }, }, }) return defaultRules } func newIPAllowlistRules(clientIPAllowlist []string) compute.SecurityPolicyRuleTypeArray { ipRanges := pulumi.StringArray{} for _, ip := range clientIPAllowlist { ipRanges = append(ipRanges, pulumi.String(ip)) } var ipAllowlistRules compute.SecurityPolicyRuleTypeArray ipAllowlistRules = append(ipAllowlistRules, &compute.SecurityPolicyRuleTypeArgs{ Action: pulumi.String("allow"), Priority: pulumi.Int(1), Description: pulumi.String("IPs allowlist rule"), Match: &compute.SecurityPolicyRuleMatchArgs{ VersionedExpr: pulumi.String("SRC_IPS_V1"), Config: &compute.SecurityPolicyRuleMatchConfigArgs{ SrcIpRanges: ipRanges, }, }, }, &compute.SecurityPolicyRuleTypeArgs{ Action: pulumi.String("deny(403)"), Description: pulumi.String("Default IP fallback deny rule"), Priority: pulumi.Int(2), Match: &compute.SecurityPolicyRuleMatchArgs{ VersionedExpr: pulumi.String("SRC_IPS_V1"), Config: &compute.SecurityPolicyRuleMatchConfigArgs{ SrcIpRanges: pulumi.StringArray{ pulumi.String("*"), }, }, }, }) return ipAllowlistRules } // newPreconfiguredRules returns a list of best-practice rules to deny traffic func newPreconfiguredRules() compute.SecurityPolicyRuleTypeArray { var preconfiguredRules compute.SecurityPolicyRuleTypeArray for index, rule := range []string{ "sqli-v33-stable", "xss-v33-stable", "lfi-v33-stable", "rfi-v33-stable", "rce-v33-stable", "methodenforcement-v33-stable", "scannerdetection-v33-stable", "protocolattack-v33-stable", "sessionfixation-v33-stable", "nodejs-v33-stable", } { preconfiguredWafRule := fmt.Sprintf("evaluatePreconfiguredWaf('%s', {'sensitivity': 1})", rule) preconfiguredRules = append(preconfiguredRules, &compute.SecurityPolicyRuleTypeArgs{ Action: pulumi.String("deny(502)"), Description: pulumi.String(fmt.Sprintf("preconfigured waf rule %s", rule)), Priority: pulumi.Int(20 + index), Match: &compute.SecurityPolicyRuleMatchArgs{ Expr: &compute.SecurityPolicyRuleMatchExprArgs{ Expression: pulumi.String(preconfiguredWafRule), }, }, }) } return preconfiguredRules }