All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Make postgres and redis provisioning idempotent: return success when already provisioned with credentials stored, allowing cookbook trees to safely include explicit add-db/add-redis steps alongside auto-provisioned project creation - Update tests to reflect new idempotent behavior - Consolidate docs CI into single multi-stage Docker build (remove separate build-docs step; Dockerfile.nginx now builds Slate then serves with nginx) - Delete redundant skeleton docs/Dockerfile (replaced by multi-stage nginx image) - Add watch verb to woodpecker-deployer RBAC (required by kubectl rollout status) - Aeries Daeya cookbook: add public discovery feed (/) + character profiles (/c/:handle), characters.published/handle/tagline fields, dark pink design system, /studio/* routes, verify-public-discovery + verify-otp-endpoint smoke test steps - Fix Input.tsx: remove non-existent --border-hover CSS variable hover effect
267 lines
10 KiB
Go
267 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
)
|
|
|
|
// addInfraComponent provisions an infrastructure component (postgres, redis, gcs).
|
|
// Unlike code components, these don't scaffold files - they provision resources.
|
|
func (s *ComponentService) addInfraComponent(ctx context.Context, projectID string, componentType domain.ComponentType, name string) (*domain.Component, error) {
|
|
switch componentType {
|
|
case domain.ComponentTypePostgres:
|
|
return s.provisionPostgres(ctx, projectID, name)
|
|
case domain.ComponentTypeRedis:
|
|
return s.provisionRedis(ctx, projectID, name)
|
|
case domain.ComponentTypeGCS:
|
|
return s.provisionGCS(ctx, projectID, name)
|
|
default:
|
|
return nil, fmt.Errorf("%w: unknown infrastructure type %s", domain.ErrInvalidComponentType, componentType)
|
|
}
|
|
}
|
|
|
|
// provisionPostgres provisions a PostgreSQL/CockroachDB database for the project.
|
|
// Idempotent: returns existing component if already provisioned with credentials stored.
|
|
func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
|
if s.dbProvisioner == nil {
|
|
return nil, fmt.Errorf("database provisioner not configured")
|
|
}
|
|
|
|
// Check if database already exists for this project
|
|
existing, err := s.dbProvisioner.GetProjectDatabase(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check existing database: %w", err)
|
|
}
|
|
if existing != nil {
|
|
// Already provisioned — return success if credentials are stored (idempotent).
|
|
// This allows cookbook trees to have explicit add-db steps even though
|
|
// project creation auto-provisions the database.
|
|
if s.credentialStore != nil {
|
|
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":DATABASE_URL")
|
|
if storeErr == nil && storedURL != "" {
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Info("postgres already provisioned, returning existing (idempotent)",
|
|
logging.FieldProjectID, projectID)
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypePostgres,
|
|
Name: name,
|
|
Path: "infra/postgres",
|
|
Port: existing.Port,
|
|
Template: "postgres",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
// Credentials missing — fall through to re-provision
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Warn("database exists but DATABASE_URL not in credential store, re-provisioning",
|
|
logging.FieldProjectID, projectID)
|
|
} else {
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Info("postgres already provisioned, returning existing (idempotent)",
|
|
logging.FieldProjectID, projectID)
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypePostgres,
|
|
Name: name,
|
|
Path: "infra/postgres",
|
|
Port: existing.Port,
|
|
Template: "postgres",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Provision the database
|
|
creds, err := s.dbProvisioner.CreateProjectDatabase(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to provision database: %w", err)
|
|
}
|
|
|
|
// Store credentials if credential store is available
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
if s.credentialStore != nil {
|
|
if err := s.storeCredential(ctx, projectID, "database", "DATABASE_URL", creds.URL); err != nil {
|
|
// Rollback on credential storage failure
|
|
log.Error("failed to store DATABASE_URL, rolling back", logging.FieldError, err)
|
|
if rollbackErr := s.dbProvisioner.DeleteProjectDatabase(ctx, projectID); rollbackErr != nil {
|
|
log.Error("failed to rollback database", logging.FieldError, rollbackErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
if err := s.storeCredential(ctx, projectID, "database", "DATABASE_URL_STAGING", creds.URLStaging); err != nil {
|
|
log.Warn("failed to store DATABASE_URL_STAGING", logging.FieldError, err)
|
|
}
|
|
}
|
|
|
|
log.Info("postgres component provisioned",
|
|
logging.FieldProjectID, projectID,
|
|
"name", name,
|
|
"database", creds.DatabaseName,
|
|
)
|
|
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypePostgres,
|
|
Name: name,
|
|
Path: "infra/postgres",
|
|
Port: creds.Port,
|
|
Template: "postgres",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
|
|
// provisionRedis provisions a Redis cache for the project.
|
|
func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
|
if s.cacheProvisioner == nil {
|
|
return nil, fmt.Errorf("cache provisioner not configured")
|
|
}
|
|
|
|
// Check if cache already exists for this project
|
|
existing, err := s.cacheProvisioner.GetProjectCache(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check existing cache: %w", err)
|
|
}
|
|
if existing != nil {
|
|
// Redis user exists — check if credentials are stored.
|
|
// If they are, return success (idempotent) so cookbook trees can safely
|
|
// have add-redis steps even though project creation auto-provisions Redis.
|
|
// If not (credentials were lost), fall through to re-provision.
|
|
if s.credentialStore != nil {
|
|
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":REDIS_URL")
|
|
if storeErr == nil && storedURL != "" {
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Info("redis already provisioned, returning existing (idempotent)",
|
|
logging.FieldProjectID, projectID)
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypeRedis,
|
|
Name: name,
|
|
Path: "infra/redis",
|
|
Port: existing.Port,
|
|
Template: "redis",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
// Credentials missing from store — re-provision to recover
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Warn("redis user exists but REDIS_URL not in credential store, re-provisioning",
|
|
logging.FieldProjectID, projectID)
|
|
} else {
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Info("redis already provisioned, returning existing (idempotent)",
|
|
logging.FieldProjectID, projectID)
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypeRedis,
|
|
Name: name,
|
|
Path: "infra/redis",
|
|
Port: existing.Port,
|
|
Template: "redis",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Provision the cache
|
|
creds, err := s.cacheProvisioner.CreateProjectCache(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to provision cache: %w", err)
|
|
}
|
|
|
|
// Store credentials if credential store is available
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
if s.credentialStore != nil {
|
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_URL", creds.URL); err != nil {
|
|
// Rollback on credential storage failure
|
|
log.Error("failed to store REDIS_URL, rolling back", logging.FieldError, err)
|
|
if rollbackErr := s.cacheProvisioner.DeleteProjectCache(ctx, projectID, false); rollbackErr != nil {
|
|
log.Error("failed to rollback cache", logging.FieldError, rollbackErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_URL_STAGING", creds.URLStaging); err != nil {
|
|
log.Warn("failed to store REDIS_URL_STAGING", logging.FieldError, err)
|
|
}
|
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_PREFIX", creds.Prefix); err != nil {
|
|
log.Warn("failed to store REDIS_PREFIX", logging.FieldError, err)
|
|
}
|
|
}
|
|
|
|
log.Info("redis component provisioned",
|
|
logging.FieldProjectID, projectID,
|
|
"name", name,
|
|
"prefix", creds.Prefix,
|
|
)
|
|
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypeRedis,
|
|
Name: name,
|
|
Path: "infra/redis",
|
|
Port: creds.Port,
|
|
Template: "redis",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
|
|
// provisionGCS provisions a GCS bucket for the project.
|
|
func (s *ComponentService) provisionGCS(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
|
if s.storageProvisioner == nil {
|
|
return nil, fmt.Errorf("storage provisioner not configured")
|
|
}
|
|
|
|
// Check if bucket already exists for this project
|
|
existing, err := s.storageProvisioner.GetProjectBucket(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check existing bucket: %w", err)
|
|
}
|
|
if existing != nil {
|
|
return nil, fmt.Errorf("%w: gcs already provisioned for project %s", domain.ErrDuplicateComponent, projectID)
|
|
}
|
|
|
|
// Provision the bucket
|
|
creds, err := s.storageProvisioner.CreateProjectBucket(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to provision storage: %w", err)
|
|
}
|
|
|
|
// Store credentials if credential store is available
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
if s.credentialStore != nil {
|
|
if err := s.storeCredential(ctx, projectID, "storage", domain.CredKeyGCSBucket, creds.BucketName); err != nil {
|
|
// Rollback on credential storage failure
|
|
log.Error("failed to store GCS_BUCKET, rolling back", logging.FieldError, err)
|
|
if rollbackErr := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); rollbackErr != nil {
|
|
log.Error("failed to rollback bucket", logging.FieldError, rollbackErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
if err := s.storeCredential(ctx, projectID, "storage", domain.CredKeyGCSServiceAccountJSON, creds.ServiceAccountJSON); err != nil {
|
|
log.Warn("failed to store GCS_SERVICE_ACCOUNT_JSON", logging.FieldError, err)
|
|
}
|
|
}
|
|
|
|
log.Info("gcs component provisioned",
|
|
logging.FieldProjectID, projectID,
|
|
"name", name,
|
|
"bucket", creds.BucketName,
|
|
)
|
|
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypeGCS,
|
|
Name: name,
|
|
Path: "infra/gcs",
|
|
Port: 0,
|
|
Template: "gcs",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
}
|
|
|
|
// storeCredential stores a project-scoped credential.
|
|
func (s *ComponentService) storeCredential(ctx context.Context, projectID, category, key, value string) error {
|
|
scopedKey := projectID + ":" + key
|
|
return s.credentialStore.Set(ctx, domain.Credential{
|
|
Key: scopedKey,
|
|
Value: value,
|
|
Category: category,
|
|
})
|
|
}
|