package gcs import ( "context" "fmt" "log/slog" "os" "regexp" "strings" "time" "cloud.google.com/go/storage" "google.golang.org/api/iam/v1" "google.golang.org/api/iterator" "google.golang.org/api/option" "github.com/orchard9/rdev/internal/domain" ) // Provisioner implements port.StorageProvisioner using Google Cloud Storage. type Provisioner struct { storageClient *storage.Client iamService *iam.Service gcpProjectID string // GCP project (e.g., "threesix-prod") location string // Bucket location (e.g., "US") logger *slog.Logger } // Config holds GCS provisioner configuration. type Config struct { GoogleProjectID string // GCP project ID where buckets will be created Location string // Bucket location (default: "US") CredentialsPath string // Path to service account JSON (empty = ADC) } // NewProvisioner creates a new GCS bucket provisioner. func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) { ctx := context.Background() // Apply defaults if cfg.Location == "" { cfg.Location = "US" } // Create storage client // Note: If CredentialsPath is provided, it should be set in GOOGLE_APPLICATION_CREDENTIALS // env var before starting the service. This avoids deprecated WithCredentialsFile/JSON. // The SDK will automatically use ADC (Application Default Credentials). var opts []option.ClientOption if cfg.CredentialsPath != "" { // Set environment variable for this process so SDK uses ADC if err := os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", cfg.CredentialsPath); err != nil { return nil, fmt.Errorf("set GOOGLE_APPLICATION_CREDENTIALS: %w", err) } } storageClient, err := storage.NewClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("create storage client: %w", err) } // Create IAM service for service account management iamService, err := iam.NewService(ctx, opts...) if err != nil { return nil, fmt.Errorf("create IAM service: %w", err) } p := &Provisioner{ storageClient: storageClient, iamService: iamService, gcpProjectID: cfg.GoogleProjectID, location: cfg.Location, logger: logger, } // Verify connection if err := p.TestConnection(ctx); err != nil { return nil, fmt.Errorf("gcs connection test failed: %w", err) } return p, nil } // CreateProjectBucket provisions a GCS bucket with service account and IAM bindings. func (p *Provisioner) CreateProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error) { bucketName := p.bucketNameFor(projectID) saEmail := p.serviceAccountEmailFor(projectID) // 1. Check if bucket already exists bucket := p.storageClient.Bucket(bucketName) if _, err := bucket.Attrs(ctx); err == nil { return nil, fmt.Errorf("bucket already exists: %s", bucketName) } // 2. Create bucket with lifecycle rules if err := bucket.Create(ctx, p.gcpProjectID, &storage.BucketAttrs{ Location: p.location, StorageClass: "STANDARD", Lifecycle: storage.Lifecycle{ Rules: []storage.LifecycleRule{ // Delete temp files after 24 hours { Action: storage.LifecycleAction{Type: "Delete"}, Condition: storage.LifecycleCondition{ MatchesPrefix: []string{"temp/"}, AgeInDays: 1, }, }, }, }, CORS: []storage.CORS{ { MaxAge: 3600 * time.Second, Methods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, Origins: []string{"https://*.threesix.ai"}, ResponseHeaders: []string{"Content-Type", "ETag"}, }, }, }); err != nil { return nil, fmt.Errorf("create bucket: %w", err) } // 3. Create service account accountID := fmt.Sprintf("project-%s-storage", sanitizeForGCP(projectID)) sa, err := p.iamService.Projects.ServiceAccounts.Create( fmt.Sprintf("projects/%s", p.gcpProjectID), &iam.CreateServiceAccountRequest{ AccountId: accountID, ServiceAccount: &iam.ServiceAccount{ DisplayName: fmt.Sprintf("Storage for %s", projectID), }, }, ).Context(ctx).Do() if err != nil { // Rollback bucket _ = bucket.Delete(ctx) return nil, fmt.Errorf("create service account: %w", err) } // 4. Grant bucket permissions to service account policy, err := bucket.IAM().Policy(ctx) if err != nil { return nil, fmt.Errorf("get bucket policy: %w", err) } policy.Add(saEmail, "roles/storage.objectAdmin") if err := bucket.IAM().SetPolicy(ctx, policy); err != nil { return nil, fmt.Errorf("set bucket policy: %w", err) } // 5. Create service account key key, err := p.iamService.Projects.ServiceAccounts.Keys.Create( sa.Name, &iam.CreateServiceAccountKeyRequest{}, ).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("create service account key: %w", err) } // 6. Return credentials return &domain.StorageCredentials{ ProjectID: projectID, BucketName: bucketName, ServiceAccountJSON: key.PrivateKeyData, // Base64-encoded JSON Location: p.location, PublicURLPrefix: fmt.Sprintf("https://storage.googleapis.com/%s", bucketName), CreatedAt: time.Now(), }, nil } // DeleteProjectBucket removes bucket and service account. func (p *Provisioner) DeleteProjectBucket(ctx context.Context, projectID string, force bool) error { bucketName := p.bucketNameFor(projectID) bucket := p.storageClient.Bucket(bucketName) // Delete all objects if force=true if force { it := bucket.Objects(ctx, nil) for { attrs, err := it.Next() if err == iterator.Done { break } if err != nil { return fmt.Errorf("list objects: %w", err) } if err := bucket.Object(attrs.Name).Delete(ctx); err != nil { p.logger.Warn("failed to delete object", "name", attrs.Name, "error", err) } } } // Delete bucket if err := bucket.Delete(ctx); err != nil { return fmt.Errorf("delete bucket: %w", err) } // Delete service account saEmail := p.serviceAccountEmailFor(projectID) saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", p.gcpProjectID, saEmail) if _, err := p.iamService.Projects.ServiceAccounts.Delete(saName).Context(ctx).Do(); err != nil { p.logger.Warn("failed to delete service account", "email", saEmail, "error", err) } return nil } // GetProjectBucket checks if bucket exists and returns metadata. func (p *Provisioner) GetProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error) { bucketName := p.bucketNameFor(projectID) bucket := p.storageClient.Bucket(bucketName) attrs, err := bucket.Attrs(ctx) if err != nil { if err == storage.ErrBucketNotExist { return nil, nil } return nil, fmt.Errorf("get bucket attrs: %w", err) } return &domain.StorageCredentials{ ProjectID: projectID, BucketName: bucketName, Location: attrs.Location, PublicURLPrefix: fmt.Sprintf("https://storage.googleapis.com/%s", bucketName), CreatedAt: attrs.Created, }, nil } // TestConnection verifies GCS connectivity. func (p *Provisioner) TestConnection(ctx context.Context) error { // Try to list buckets as connection test it := p.storageClient.Buckets(ctx, p.gcpProjectID) if _, err := it.Next(); err != nil && err != iterator.Done { return fmt.Errorf("list buckets failed: %w", err) } return nil } // Close cleans up resources. func (p *Provisioner) Close() error { return p.storageClient.Close() } // Helper functions func (p *Provisioner) bucketNameFor(projectID string) string { return fmt.Sprintf("project-%s-media", sanitizeForGCP(projectID)) } func (p *Provisioner) serviceAccountEmailFor(projectID string) string { return fmt.Sprintf("project-%s-storage@%s.iam.gserviceaccount.com", sanitizeForGCP(projectID), p.gcpProjectID) } func sanitizeForGCP(s string) string { // GCP resource names: lowercase alphanumeric and hyphens only s = strings.ToLower(s) s = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(s, "-") s = strings.Trim(s, "-") return s }