rdev/internal/adapter/gcs/provisioner.go
jordan adcea2fc1f
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(templates): upgrade Go to 1.25 and fix Woodpecker syntax
## Template Version Alignment
- Go: 1.23 → 1.25 across all templates (go.work, go.mod, Dockerfiles, CI)
- Alpine: latest → 3.19 (explicit version pinning)
- Woodpecker: failure:retry → failure:ignore (invalid syntax fix)

## SDLC Tree Fixes (slackpath-5-full-lifecycle)
Fixed merge failures by correcting lifecycle flow:

1. **Branch Creation**: Added missing create-branch step (planned → ready)
   - Bug: Merge command requires feature.Branch field to be set
   - Fix: POST /projects/{id}/sdlc/features/{slug}/branch

2. **Artifact Status**: Changed approval to pass for execution artifacts
   - Bug: Review/audit/QA need status="passed" not "approved"
   - Fix: /artifacts/{type}/approve → /artifacts/{type}/pass
   - Added: pass-qa step after wait-qa

3. **Phase Transition Order**: Reordered merge phase transition
   - Bug: Merge command checks if phase == "merge" first
   - Fix: transition-to-merge BEFORE merge-feature (not after)

## GCS Provisioner Fix
- Replaced deprecated option.WithCredentialsFile with env var approach
- Now uses GOOGLE_APPLICATION_CREDENTIALS for ADC (Application Default Credentials)
- Avoids security risk from deprecated credential options
- Fixed test: Added ComponentTypeGCS to ValidComponentTypes test

## Critical Rules Added
- Version alignment: All template versions must stay in sync
- When updating versions, grep entire templates/ tree

## Files Changed
- 27 template files: Go version + Woodpecker syntax
- 1 tree file: SDLC lifecycle flow corrections
- 1 CLAUDE.md: Version alignment rule
- 1 GCS provisioner: Deprecated API fix
- 1 test file: Added missing component type

Root cause: Skeleton templates lagged behind Go 1.25 release and had
invalid Woodpecker syntax. SDLC tree skipped required branch creation
and used wrong artifact approval endpoints.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 23:57:38 -07:00

262 lines
7.7 KiB
Go

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
}