All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
CI / Woodpecker: - Add explicit depends_on to all .woodpecker.yml steps (rdev + templates) - Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name) - Add replicasets get/list to deployer RBAC for rollout status - Skeleton template: add failure:ignore on docs steps, Traefik TLS annotations on ingress, depends_on on verify step Component templates: - Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME) - Replace kubectl scale with kubectl patch for replicas - Add post-deploy image verification and rollout status checks - Applied consistently across all 5 component templates Adapters: - gitea: Add HTTP client timeout (30s), context cancellation checks, handle 404 on GetRepo/DeleteRepo - zot: Add retry with exponential backoff (doWithRetry), limit response body reads to 10MB - cockroach: Use net.JoinHostPort for IPv6-safe DSN construction - woodpecker: Fix error wrapping (%v -> %w) - redis: Fix error wrapping (%v -> %w) - deployer: Add context cancellation checks Services: - apikey_service: Fix error wrapping (%v -> %w) - component_deploy: Fix error wrapping (%v -> %w) - project_infra: Fix error wrapping (%v -> %w) - webhook/dispatcher: Fix error wrapping (%v -> %w) Other: - CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3, Traefik v3, Zot registry - circuitbreaker: Add test for error wrapping - docs: Update deployment, troubleshooting, and runbook docs - health: Fix error wrapping (%v -> %w) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
7.6 KiB
Go
251 lines
7.6 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"
|
|
"net"
|
|
"strconv"
|
|
"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"
|
|
}
|
|
|
|
hostPort := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
|
dsn := fmt.Sprintf("postgresql://%s@%s/defaultdb?sslmode=%s",
|
|
cfg.User, hostPort, 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
|
|
hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port))
|
|
url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable",
|
|
username, hostPort, 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
|
|
hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port))
|
|
url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable",
|
|
username, hostPort, 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
|
|
}
|