// 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.cluster.local" 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 }