rdev/internal/adapter/cockroach/provisioner.go
jordan c59d348040 chore: prepare for composable monorepo template implementation
This commit captures the current state before implementing the composable
monorepo template system. Key changes included:

Infrastructure:
- Add CockroachDB provisioner adapter for database provisioning
- Add Redis provisioner adapter for cache provisioning
- Add build events system with PostgreSQL storage
- Add WebSocket endpoint for real-time build progress

Code agent improvements:
- Fix Claude Code adapter to use default allowed tools instead of dangerously-skip-permissions
- Add context-aware stream closing for cancellation support
- Improve parser tests for edge cases

Build system:
- Add build event constants and metrics
- Remove deprecated git_operations.go (replaced by pod_git_operations.go)
- Add rollback logic for multi-step provisioning operations

Documentation:
- Add composable-monorepo feature documentation
- Add DNS/Cloudflare service documentation
- Update deployment and troubleshooting guides

Cookbooks:
- Add fullstack-app cookbook
- Refactor landing-test with shared library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:39:28 -07:00

246 lines
7.4 KiB
Go

// Package cockroach provides CockroachDB database provisioning for projects.
// Creates isolated databases and users for each project.
package cockroach
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"time"
_ "github.com/lib/pq" // PostgreSQL driver (CockroachDB is PG-compatible)
"github.com/orchard9/rdev/internal/domain"
)
// Provisioner implements port.DatabaseProvisioner using CockroachDB.
type Provisioner struct {
db *sql.DB
host string
port int
logger *slog.Logger
}
// Config holds CockroachDB provisioner configuration.
type Config struct {
Host string // e.g., "cockroachdb-public.databases.svc"
Port int // e.g., 26257
User string // e.g., "root" (for insecure mode)
SSLMode string // e.g., "disable" (for insecure mode)
}
// NewProvisioner creates a new CockroachDB database provisioner.
func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) {
if cfg.SSLMode == "" {
cfg.SSLMode = "disable"
}
dsn := fmt.Sprintf("postgresql://%s@%s:%d/defaultdb?sslmode=%s",
cfg.User, cfg.Host, cfg.Port, cfg.SSLMode)
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open connection: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
// Verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("cockroachdb connection failed: %w", err)
}
return &Provisioner{
db: db,
host: cfg.Host,
port: cfg.Port,
logger: logger,
}, nil
}
// CreateProjectDatabase provisions an isolated database for a project.
func (p *Provisioner) CreateProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) {
dbName := p.databaseNameFor(projectID)
username := p.usernameFor(projectID)
password, err := generateToken(32)
if err != nil {
return nil, fmt.Errorf("generate password: %w", err)
}
// Check if database already exists
var exists bool
err = p.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE catalog_name = $1)",
dbName).Scan(&exists)
if err != nil {
return nil, fmt.Errorf("check database exists: %w", err)
}
if exists {
p.logger.Warn("database already exists, recreating user",
"project_id", projectID,
"database", dbName)
// Drop existing user to recreate with new password
_, _ = p.db.ExecContext(ctx, fmt.Sprintf("DROP USER IF EXISTS %s", quoteIdent(username)))
}
// Create database
if _, err := p.db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdent(dbName))); err != nil {
return nil, fmt.Errorf("create database: %w", err)
}
// Create user
// In CockroachDB insecure mode, passwords are not enforced but we store one for future TLS mode
if _, err := p.db.ExecContext(ctx, fmt.Sprintf("CREATE USER IF NOT EXISTS %s", quoteIdent(username))); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
// Grant permissions
if _, err := p.db.ExecContext(ctx, fmt.Sprintf("GRANT ALL ON DATABASE %s TO %s", quoteIdent(dbName), quoteIdent(username))); err != nil {
return nil, fmt.Errorf("grant permissions: %w", err)
}
// Build connection URL
// In insecure mode, password is not used in connection, but we store it for future TLS migration
url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable",
username, p.host, p.port, dbName)
p.logger.Info("created project database",
"project_id", projectID,
"database", dbName,
"username", username)
return &domain.DatabaseCredentials{
ProjectID: projectID,
DatabaseName: dbName,
Username: username,
Password: password,
Host: p.host,
Port: p.port,
SSLMode: "disable",
URL: url,
URLStaging: url, // Same for now; separate staging cluster in future
CreatedAt: time.Now().UTC(),
}, nil
}
// DeleteProjectDatabase removes database access for a project.
func (p *Provisioner) DeleteProjectDatabase(ctx context.Context, projectID string) error {
dbName := p.databaseNameFor(projectID)
username := p.usernameFor(projectID)
// Revoke permissions first
_, _ = p.db.ExecContext(ctx, fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", quoteIdent(dbName), quoteIdent(username)))
// Drop database (CASCADE drops all tables, indexes, etc.)
if _, err := p.db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s CASCADE", quoteIdent(dbName))); err != nil {
p.logger.Warn("failed to drop database", "database", dbName, "error", err)
}
// Drop user
if _, err := p.db.ExecContext(ctx, fmt.Sprintf("DROP USER IF EXISTS %s", quoteIdent(username))); err != nil {
p.logger.Warn("failed to drop user", "username", username, "error", err)
}
p.logger.Info("deleted project database",
"project_id", projectID,
"database", dbName,
"username", username)
return nil
}
// GetProjectDatabase retrieves database credentials for a project.
// Note: Password cannot be retrieved from CockroachDB; use stored credentials.
func (p *Provisioner) GetProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) {
dbName := p.databaseNameFor(projectID)
username := p.usernameFor(projectID)
// Check if database exists
var exists bool
err := p.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE catalog_name = $1)",
dbName).Scan(&exists)
if err != nil {
return nil, fmt.Errorf("check database exists: %w", err)
}
if !exists {
return nil, nil // Database not provisioned
}
// Database exists; construct credentials without password
url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable",
username, p.host, p.port, dbName)
return &domain.DatabaseCredentials{
ProjectID: projectID,
DatabaseName: dbName,
Username: username,
Password: "", // Not available; use credential store
Host: p.host,
Port: p.port,
SSLMode: "disable",
URL: url,
URLStaging: url,
}, nil
}
// TestConnection verifies CockroachDB connectivity.
func (p *Provisioner) TestConnection(ctx context.Context) error {
return p.db.PingContext(ctx)
}
// Close closes the database connection.
func (p *Provisioner) Close() error {
return p.db.Close()
}
// databaseNameFor returns the database name for a project.
func (p *Provisioner) databaseNameFor(projectID string) string {
return "project_" + sanitizeIdentifier(projectID)
}
// usernameFor returns the database username for a project.
func (p *Provisioner) usernameFor(projectID string) string {
return "project_" + sanitizeIdentifier(projectID)
}
// sanitizeIdentifier sanitizes a string for use as a SQL identifier.
// Replaces non-alphanumeric characters with underscores and lowercases.
func sanitizeIdentifier(s string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + 32 // lowercase
}
return '_'
}, s)
}
// quoteIdent quotes a SQL identifier to prevent injection.
// CockroachDB uses double quotes for identifiers.
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// generateToken generates a cryptographically secure random token.
func generateToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}