// 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)
}
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)
}
// 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)
}
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)
}
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", 63)
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", 63)
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 (
"fmt"
"log"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// Env vars for bucket configuration
//
//nolint:revive // Environment variable names should match their actual env var names
const (
BUCKET_NAME = "BUCKET_NAME"
)
// deployBucket creates a Cloud Storage bucket with best-practice security and lifecycle policies
func (f *FullStack) deployBucket(ctx *pulumi.Context, args *BucketInstanceArgs) error {
if err := ctx.Log.Debug("Deploying Cloud Storage bucket with config: %v", &pulumi.LogArgs{
Resource: f,
}); err != nil {
log.Printf("failed to log bucket deployment with pulumi context: %v", err)
}
storageAPI, err := f.enableStorageAPI(ctx)
if err != nil {
return fmt.Errorf("failed to enable Storage API: %w", err)
}
bucket, err := f.createStorageBucket(ctx, args, storageAPI)
if err != nil {
return fmt.Errorf("failed to create storage bucket: %w", err)
}
f.storageBucket = bucket
return nil
}
// enableStorageAPI enables the Cloud Storage API service
func (f *FullStack) enableStorageAPI(ctx *pulumi.Context) (*projects.Service, error) {
return projects.NewService(ctx, f.NewResourceName("bucket", "storage-api", 63), &projects.ServiceArgs{
Project: pulumi.String(f.Project),
Service: pulumi.String("storage.googleapis.com"),
DisableOnDestroy: pulumi.Bool(false),
DisableDependentServices: pulumi.Bool(false),
},
pulumi.Parent(f),
pulumi.RetainOnDelete(true),
)
}
// createStorageBucket creates a Cloud Storage bucket with security and lifecycle policies
func (f *FullStack) createStorageBucket(ctx *pulumi.Context, config *BucketInstanceArgs, storageAPI *projects.Service) (*storage.Bucket, error) {
// Set defaults if not provided
applyBucketConfigDefaults(config)
bucketName := f.NewResourceName("bucket", "storage", 63)
return storage.NewBucket(ctx, bucketName, &storage.BucketArgs{
Name: pulumi.String(bucketName),
Project: pulumi.String(f.Project),
Location: pulumi.String(config.Location),
StorageClass: pulumi.String(config.StorageClass),
Labels: mergeLabels(f.Labels, pulumi.StringMap{"bucket": pulumi.String("true")}),
ForceDestroy: pulumi.Bool(config.ForceDestroy),
// Enable uniform bucket-level access for better security
UniformBucketLevelAccess: pulumi.Bool(true),
// Prevent public access
PublicAccessPrevention: pulumi.String("enforced"),
// Enable versioning for data protection
Versioning: &storage.BucketVersioningArgs{
Enabled: pulumi.Bool(true),
},
// Google-managed encryption (default)
Encryption: &storage.BucketEncryptionArgs{
DefaultKmsKeyName: pulumi.String(""),
},
// Lifecycle management for cost optimization
LifecycleRules: storage.BucketLifecycleRuleArray{
&storage.BucketLifecycleRuleArgs{
Action: &storage.BucketLifecycleRuleActionArgs{
Type: pulumi.String("Delete"),
},
Condition: &storage.BucketLifecycleRuleConditionArgs{
Age: pulumi.Int(config.RetentionDays),
},
},
// Delete old versions after 30 days
&storage.BucketLifecycleRuleArgs{
Action: &storage.BucketLifecycleRuleActionArgs{
Type: pulumi.String("Delete"),
},
Condition: &storage.BucketLifecycleRuleConditionArgs{
NumNewerVersions: pulumi.Int(10), // Keep up to 10 versions
Age: pulumi.Int(30), // Delete versions older than 30 days
},
},
},
// CORS configuration to allow access from Cloud Run
Cors: storage.BucketCorArray{
&storage.BucketCorArgs{
Origins: pulumi.StringArray{
pulumi.String("https://*.run.app"),
pulumi.String("https://*.googleapis.com"),
},
Methods: pulumi.StringArray{
pulumi.String("GET"),
pulumi.String("POST"),
pulumi.String("PUT"),
pulumi.String("DELETE"),
pulumi.String("HEAD"),
},
ResponseHeaders: pulumi.StringArray{
pulumi.String("*"),
},
MaxAgeSeconds: pulumi.Int(3600),
},
},
}, pulumi.Parent(f), pulumi.DependsOn([]pulumi.Resource{storageAPI}))
}
func applyBucketConfigDefaults(config *BucketInstanceArgs) {
if config.StorageClass == "" {
config.StorageClass = "STANDARD"
}
if config.Location == "" {
config.Location = "US"
}
if config.RetentionDays == 0 {
config.RetentionDays = 365
}
// ForceDestroy defaults to false for safety
}
// 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"),
DisableOnDestroy: pulumi.Bool(false),
DisableDependentServices: pulumi.Bool(false),
},
pulumi.Parent(f),
pulumi.RetainOnDelete(true),
)
}
// 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"),
DisableOnDestroy: pulumi.Bool(false),
DisableDependentServices: pulumi.Bool(false),
},
pulumi.Parent(f),
pulumi.RetainOnDelete(true),
)
if err != nil {
return nil, fmt.Errorf("failed to enable VPC access API: %w", err)
}
connectorName := f.NewResourceName("cache", "vpc-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),
pulumi.RetainOnDelete(true),
)
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", 63)
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"
"log"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrun"
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: 10,
PeriodSeconds: 2,
TimeoutSeconds: 1,
FailureThreshold: 3,
}
}
// Set default liveness probe if not provided
if args.LivenessProbe == nil {
args.LivenessProbe = &Probe{
Path: "healthz",
InitialDelaySeconds: 15,
PeriodSeconds: 5,
TimeoutSeconds: 3,
FailureThreshold: 3,
}
}
if args.Secrets == nil {
args.Secrets = []*SecretVolumeArgs{}
}
if args.ColdStartSLO != nil {
setColdStartSLODefaults(args.ColdStartSLO)
}
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)
}
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", 63)
serviceTemplate := &cloudrunv2.ServiceTemplateArgs{
Scaling: &cloudrunv2.ServiceTemplateScalingArgs{
MaxInstanceCount: pulumi.Int(args.MaxInstanceCount),
},
Containers: cloudrunv2.ServiceTemplateContainerArray{
&cloudrunv2.ServiceTemplateContainerArgs{
Image: f.BackendImage,
Envs: newBackendEnvVars(args, f.AppBaseURL),
Resources: &cloudrunv2.ServiceTemplateContainerResourcesArgs{
CpuIdle: pulumi.Bool(true),
Limits: args.ResourceLimits,
StartupCpuBoost: pulumi.Bool(args.StartupCPUBoost),
},
Ports: cloudrunv2.ServiceTemplateContainerPortsArgs{
ContainerPort: pulumi.Int(args.ContainerPort),
},
StartupProbe: startupProbe(args.ContainerPort, args.StartupProbe),
LivenessProbe: livenessProbe(args.ContainerPort, args.LivenessProbe.Path, args.LivenessProbe),
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"),
}
}
ingress := "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
if args.EnablePublicIngress {
// The instance is likely using an external WAF. Make it reachable.
ingress = "INGRESS_TRAFFIC_ALL"
}
backendService, err := cloudrunv2.NewService(ctx, backendServiceName, &cloudrunv2.ServiceArgs{
Name: pulumi.String(backendServiceName),
Ingress: pulumi.String(ingress),
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)
}
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)
}
if args.ColdStartSLO != nil {
backendSLO, err := f.setupColdStartSLO(ctx, backendServiceName, args.ColdStartSLO)
if err != nil {
return nil, nil, fmt.Errorf("failed to setup cold start SLO for backend Cloud Run service: %w", err)
}
f.backendColdStartSLO = backendSLO
}
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), 63)
_, 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)
}
}
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 f.storageBucket != nil {
// Allow backend to access storage bucket objects
instanceRoles = append(instanceRoles, "roles/storage.objectAdmin")
}
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)
}
}
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)
}
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", 63)
frontendServiceTemplate := &cloudrunv2.ServiceTemplateArgs{
Scaling: &cloudrunv2.ServiceTemplateScalingArgs{
MaxInstanceCount: pulumi.Int(args.MaxInstanceCount),
},
Containers: cloudrunv2.ServiceTemplateContainerArray{
&cloudrunv2.ServiceTemplateContainerArgs{
Image: frontendImage,
Resources: &cloudrunv2.ServiceTemplateContainerResourcesArgs{
// Stay serverless. Optimize for cold starts.
CpuIdle: pulumi.Bool(true),
Limits: args.ResourceLimits,
StartupCpuBoost: pulumi.Bool(args.StartupCPUBoost),
},
Ports: cloudrunv2.ServiceTemplateContainerPortsArgs{
ContainerPort: pulumi.Int(args.ContainerPort),
},
// TODO get app base url from input
Envs: newFrontendEnvVars(args, backendURL, f.AppBaseURL),
StartupProbe: startupProbe(args.ContainerPort, args.StartupProbe),
LivenessProbe: livenessProbe(args.ContainerPort, args.LivenessProbe.Path, args.LivenessProbe),
VolumeMounts: volumeMounts,
},
},
ServiceAccount: serviceAccount.Email,
Volumes: volumes,
}
ingress := "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
if args.EnablePublicIngress {
// The instance is likely using an external WAF. Make it reachable.
ingress = "INGRESS_TRAFFIC_ALL"
}
frontendService, err := cloudrunv2.NewService(ctx, frontendServiceName, &cloudrunv2.ServiceArgs{
Name: pulumi.String(frontendServiceName),
Ingress: pulumi.String(ingress),
Description: pulumi.String(fmt.Sprintf("Serverless instance (%s)", serviceName)),
Location: pulumi.String(region),
Project: pulumi.String(project),
Labels: frontendLabels,
Template: frontendServiceTemplate,
DeletionProtection: pulumi.Bool(args.DeletionProtection),
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create frontend Cloud Run service: %w", err)
}
if args.ColdStartSLO != nil {
frontendSLO, err := f.setupColdStartSLO(ctx, frontendServiceName, args.ColdStartSLO)
if err != nil {
return nil, nil, fmt.Errorf("failed to setup cold start SLO for backend Cloud Run service: %w", err)
}
f.frontendColdStartSLO = frontendSLO
}
return frontendService, serviceAccount, 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)
}
return nil
}
func newFrontendEnvVars(args *FrontendArgs, backendURL pulumi.StringOutput, appBaseURL string) 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("APP_BASE_URL"),
Value: pulumi.String(appBaseURL),
},
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, appBaseURL string) 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("APP_BASE_URL"),
Value: pulumi.String(appBaseURL),
},
}
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
}
func (f *FullStack) createInstanceDomainMapping(
ctx *pulumi.Context,
serviceName string,
domainURL string,
targetInstanceName pulumi.StringOutput,
) (*cloudrun.DomainMapping, error) {
domainMappingName := f.NewResourceName(serviceName, "domain-mapping", 63)
domainMapping, err := cloudrun.NewDomainMapping(ctx, domainMappingName, &cloudrun.DomainMappingArgs{
Location: pulumi.String(f.Region),
Name: pulumi.String(domainURL),
Metadata: &cloudrun.DomainMappingMetadataArgs{
Namespace: pulumi.String(f.Project),
},
Spec: &cloudrun.DomainMappingSpecArgs{
RouteName: targetInstanceName,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create custom domain mapping: %w", err)
}
return domainMapping, nil
}
func startupProbe(port int, probe *Probe) *cloudrunv2.ServiceTemplateContainerStartupProbeArgs {
return &cloudrunv2.ServiceTemplateContainerStartupProbeArgs{
TcpSocket: &cloudrunv2.ServiceTemplateContainerStartupProbeTcpSocketArgs{
Port: pulumi.Int(port),
},
InitialDelaySeconds: pulumi.Int(probe.InitialDelaySeconds),
PeriodSeconds: pulumi.Int(probe.PeriodSeconds),
TimeoutSeconds: pulumi.Int(probe.TimeoutSeconds),
FailureThreshold: pulumi.Int(probe.FailureThreshold),
}
}
func livenessProbe(port int, path string, probe *Probe) *cloudrunv2.ServiceTemplateContainerLivenessProbeArgs {
return &cloudrunv2.ServiceTemplateContainerLivenessProbeArgs{
HttpGet: &cloudrunv2.ServiceTemplateContainerLivenessProbeHttpGetArgs{
Path: pulumi.String(fmt.Sprintf("/%s", path)),
Port: pulumi.Int(port),
},
InitialDelaySeconds: pulumi.Int(probe.InitialDelaySeconds),
PeriodSeconds: pulumi.Int(probe.PeriodSeconds),
TimeoutSeconds: pulumi.Int(probe.TimeoutSeconds),
FailureThreshold: pulumi.Int(probe.FailureThreshold),
}
}
// 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"
namer "github.com/davidmontoyago/commodity-namer"
apigateway "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/apigateway"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrun"
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/storage"
"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
namer.Namer
Project string
Region string
BackendName string
BackendImage pulumi.StringOutput
FrontendName string
FrontendImage pulumi.StringOutput
Labels map[string]string
AppBaseURL string
gatewayEnabled bool
loadBalancerEnabled bool
backendService *cloudrunv2.Service
backendAccount *serviceaccount.Account
backendColdStartSLO *ColdStartSLO
frontendService *cloudrunv2.Service
frontendAccount *serviceaccount.Account
frontendColdStartSLO *ColdStartSLO
// 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
// Domain mappings to use when the external LB is disabled and External WAF is used
backendDomainMapping *cloudrun.DomainMapping
frontendDomainMapping *cloudrun.DomainMapping
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
// Storage infrastructure
storageBucket *storage.Bucket
}
// 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"
}
appBaseURL := ""
if args.Network != nil {
appBaseURL = fmt.Sprintf("https://%s", args.Network.DomainURL)
}
gatewayEnabled := args.Network != nil && args.Network.APIGateway != nil && !args.Network.APIGateway.Disabled
loadBalancerEnabled := args.Network != nil && !args.Network.EnableExternalWAF
fullStack := &FullStack{
Project: args.Project,
Region: args.Region,
BackendImage: args.BackendImage.ToStringOutput(),
FrontendImage: args.FrontendImage.ToStringOutput(),
BackendName: backendName,
FrontendName: frontendName,
Labels: args.Labels,
AppBaseURL: appBaseURL,
Namer: namer.New(name),
gatewayEnabled: gatewayEnabled,
loadBalancerEnabled: loadBalancerEnabled,
}
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)
}
// Register important outputs for the component
outputs := pulumi.Map{
"backendServiceUrl": pulumi.String(""),
"frontendServiceUrl": pulumi.String(""),
"gatewayEnabled": pulumi.Bool(fullStack.gatewayEnabled),
"loadBalancerEnabled": pulumi.Bool(fullStack.loadBalancerEnabled),
"appBaseURL": pulumi.String(fullStack.AppBaseURL),
}
if fullStack.backendService != nil {
outputs["backendServiceUrl"] = fullStack.backendService.Uri
}
if fullStack.frontendService != nil {
outputs["frontendServiceUrl"] = fullStack.frontendService.Uri
}
err = ctx.RegisterResourceOutputs(fullStack, outputs)
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)
}
}
if args.Backend != nil && args.Backend.BucketInstance != nil {
// Deploy bucket companion for backend
err := f.deployBucket(ctx, args.Backend.BucketInstance)
if err != nil {
return fmt.Errorf("failed to deploy bucket: %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)
}
}
if f.loadBalancerEnabled {
// 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)
}
} else if args.Network.EnableExternalWAF {
// create domain mappings for the backend and frontend services
backendDomainMapping, err := f.createInstanceDomainMapping(
ctx,
f.BackendName,
fmt.Sprintf("api-%s", args.Network.DomainURL),
backendService.Name,
)
if err != nil {
return fmt.Errorf("failed to create backend domain mapping: %w", err)
}
f.backendDomainMapping = backendDomainMapping
frontendDomainMapping, err := f.createInstanceDomainMapping(
ctx,
f.FrontendName,
args.Network.DomainURL,
frontendService.Name,
)
if err != nil {
return fmt.Errorf("failed to create frontend domain mapping: %w", err)
}
f.frontendDomainMapping = frontendDomainMapping
// TODO allow backend and frontend to have separate URLs
}
return nil
}
// 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
}
// GetStorageBucket returns the Cloud Storage bucket.
func (f *FullStack) GetStorageBucket() *storage.Bucket {
return f.storageBucket
}
// GetBackendDomainMapping returns the backend domain mapping for External WAF.
func (f *FullStack) GetBackendDomainMapping() *cloudrun.DomainMapping {
return f.backendDomainMapping
}
// GetFrontendDomainMapping returns the frontend domain mapping for External WAF.
// TODO: implement frontend domain mapping functionality
func (f *FullStack) GetFrontendDomainMapping() *cloudrun.DomainMapping {
return f.frontendDomainMapping
}
// GetBackendColdStartSLO returns the backend cold start SLO.
func (f *FullStack) GetBackendColdStartSLO() *ColdStartSLO {
return f.backendColdStartSLO
}
// GetFrontendColdStartSLO returns the frontend cold start SLO.
func (f *FullStack) GetFrontendColdStartSLO() *ColdStartSLO {
return f.frontendColdStartSLO
}
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", 63)
_, err := projects.NewService(ctx, identityPlatformName, &projects.ServiceArgs{
Project: pulumi.String(f.Project),
Service: pulumi.String("cloudidentity.googleapis.com"),
},
pulumi.RetainOnDelete(true),
)
if err != nil {
return fmt.Errorf("failed to enable cloudidentity API: %w", err)
}
idToolkitName := f.NewResourceName(endpointName, "idtoolkit", 63)
_, err = projects.NewService(ctx, idToolkitName, &projects.ServiceArgs{
Project: pulumi.String(f.Project),
Service: pulumi.String("identitytoolkit.googleapis.com"),
},
pulumi.RetainOnDelete(true),
)
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", 63)
// _, 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", 63)
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)
}
f.certificate = certificate
httpsProxyName := f.NewResourceName(serviceName, "https-proxy", 63)
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)
}
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", 63)
_, 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)
}
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)
}
}
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", 63)
// 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", 63)
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
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", 63)
lbGatewayBackendService, err := compute.NewBackendService(ctx, backendServiceName, lbGatewayServiceArgs)
if err != nil {
return nil, fmt.Errorf("failed to create Gateway backend service: %w", err)
}
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", 63)
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
cloudrunFrontendNegName := f.NewResourceName(serviceName, "frontend-cloudrun-neg", 63)
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
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", 63)
backendService, err := compute.NewBackendService(ctx, backendServiceName, lbBackendServiceArgs)
if err != nil {
return nil, nil, fmt.Errorf("failed to create backend service for NEG: %w", err)
}
frontendServiceName := f.NewResourceName(serviceName, "cloudrun-frontend-service", 63)
frontendService, err := compute.NewBackendService(ctx, frontendServiceName, lbFrontendServiceArgs)
if err != nil {
return nil, nil, fmt.Errorf("failed to create frontend service for NEG: %w", err)
}
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", 63)
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", 63)
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)
}
// https://cloud.google.com/load-balancing/docs/https#forwarding-rule
forwardingRuleName := f.NewResourceName(serviceName, "https-forwarding", 63)
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)
}
// 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", 63)
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)
}
// Create a regional forwarding rule pointing to the global Target HTTPS Proxy (classic ALB)
forwardingRuleName := f.NewResourceName(serviceName, "regional-https-forwarding", 63)
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)
}
// 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", 63)
// 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
}
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", 63)
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", 63)
_, 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", 63)
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)
}
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
}
package gcp
import (
"fmt"
"strings"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/monitoring"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// ColdStartSLO tracks the container startup latency SLO
type ColdStartSLO struct {
Slo *monitoring.Slo
AlertPolicy *monitoring.AlertPolicy
}
// setupColdStartSLO creates a Cloud Run Cold Start SLO to measure
// and optimized for faster boot times.
func (f *FullStack) setupColdStartSLO(ctx *pulumi.Context, cloudRunServiceName string, args *ColdStartSLOArgs) (*ColdStartSLO, error) {
// Create a microservice to associate with the SLO
// See:
// https://cloud.google.com/stackdriver/docs/solutions/slo-monitoring/ui/define-svc
customServiceName := f.NewResourceName(cloudRunServiceName, "monitoring-service", 63)
monitoringService, err := monitoring.NewGenericService(ctx, customServiceName, &monitoring.GenericServiceArgs{
Project: pulumi.String(f.Project),
DisplayName: pulumi.String("Cloud Run Cold Start SLO monitored service"),
ServiceId: pulumi.String(customServiceName),
BasicService: &monitoring.GenericServiceBasicServiceArgs{
ServiceType: pulumi.String("CLOUD_RUN"),
ServiceLabels: pulumi.StringMap{
"service_name": pulumi.String(cloudRunServiceName),
"location": pulumi.String(f.Region),
},
},
})
if err != nil {
return nil, fmt.Errorf("failed to create generic service: %w", err)
}
// Create the SLO using Cloud Run's built-in container/startup_latencies metric
sloName := f.NewResourceName(cloudRunServiceName, "startup-latency-slo", 63)
slo, err := monitoring.NewSlo(ctx, sloName, &monitoring.SloArgs{
Project: pulumi.String(f.Project),
DisplayName: pulumi.String("Cloud Run Container Startup Latency SLO"),
// Reference the Cloud Run service
Service: monitoringService.ServiceId,
Goal: args.Goal,
RollingPeriodDays: args.RollingPeriodDays,
// Request-based SLI measuring latency distribution
RequestBasedSli: &monitoring.SloRequestBasedSliArgs{
// Distribution cut for latency-based SLI
DistributionCut: &monitoring.SloRequestBasedSliDistributionCutArgs{
Range: &monitoring.SloRequestBasedSliDistributionCutRangeArgs{
// Boot times should stay within this range
Min: pulumi.Float64(0.0),
Max: args.MaxBootTimeMs,
},
// Filter for the Cloud Run container startup latencies metric
DistributionFilter: pulumi.Sprintf(strings.Join([]string{
`resource.type="cloud_run_revision"`,
`resource.labels.service_name="%s"`,
`metric.type="run.googleapis.com/container/startup_latencies"`,
}, " AND "), cloudRunServiceName),
},
},
}, pulumi.DependsOn([]pulumi.Resource{monitoringService}))
if err != nil {
return nil, fmt.Errorf("failed to create cold start SLO: %w", err)
}
coldStartSLO := &ColdStartSLO{
Slo: slo,
}
// Create an alerting policy for SLO burn rate
if args.AlertChannelID != "" {
alertPolicy, err := f.setupSLOAlertPolicy(ctx, cloudRunServiceName, slo, args)
if err != nil {
return nil, fmt.Errorf("failed to create cold start SLO alert: %w", err)
}
coldStartSLO.AlertPolicy = alertPolicy
}
return coldStartSLO, nil
}
func (f *FullStack) setupSLOAlertPolicy(ctx *pulumi.Context, cloudRunServiceName string, slo *monitoring.Slo, args *ColdStartSLOArgs) (*monitoring.AlertPolicy, error) {
alertPolicyName := f.NewResourceName(cloudRunServiceName, "startup-latency-slo-alert", 63)
alertPolicy, err := monitoring.NewAlertPolicy(ctx, alertPolicyName, &monitoring.AlertPolicyArgs{
Project: pulumi.String(f.Project),
DisplayName: pulumi.String("Cloud Run Cold Start SLO Alert"),
Conditions: monitoring.AlertPolicyConditionArray{
&monitoring.AlertPolicyConditionArgs{
DisplayName: pulumi.String("SLO burn rate too high"),
ConditionThreshold: &monitoring.AlertPolicyConditionConditionThresholdArgs{
Filter: slo.Name.ApplyT(func(name string) string {
return fmt.Sprintf(strings.Join([]string{
`resource.type="gce_instance"`,
`metric.type="run.googleapis.com/container/startup_latencies"`,
`metric.labels.slo_name="%s"`,
}, " AND "), name)
}).(pulumi.StringOutput),
Comparison: pulumi.String("COMPARISON_GT"),
ThresholdValue: args.AlertBurnRateThreshold,
Duration: args.AlertThresholdDuration,
Aggregations: monitoring.AlertPolicyConditionConditionThresholdAggregationArray{
&monitoring.AlertPolicyConditionConditionThresholdAggregationArgs{
AlignmentPeriod: pulumi.String("300s"),
PerSeriesAligner: pulumi.String("ALIGN_RATE"),
},
},
},
},
},
Combiner: pulumi.String("OR"),
// Notification channel
NotificationChannels: pulumi.StringArray{
pulumi.String(fmt.Sprintf("projects/%s/notificationChannels/%s", f.Project, args.AlertChannelID)),
},
AlertStrategy: &monitoring.AlertPolicyAlertStrategyArgs{
AutoClose: pulumi.String("1800s"), // 30 minutes
},
})
if err != nil {
return nil, fmt.Errorf("failed to create cold start SLO alert: %w", err)
}
return alertPolicy, nil
}
func setColdStartSLODefaults(args *ColdStartSLOArgs) {
if args.Goal == nil {
args.Goal = pulumi.Float64(0.99)
}
if args.MaxBootTimeMs == nil {
args.MaxBootTimeMs = pulumi.Float64(1000)
}
if args.RollingPeriodDays == nil {
args.RollingPeriodDays = pulumi.Int(7)
}
if args.AlertChannelID != "" {
if args.AlertBurnRateThreshold == nil {
args.AlertBurnRateThreshold = pulumi.Float64(0.1)
}
if args.AlertThresholdDuration == nil {
args.AlertThresholdDuration = pulumi.String("86400s")
}
}
}