Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
## 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>
262 lines
7.7 KiB
Go
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
|
|
}
|