rdev/internal/adapter/redis/provisioner.go
jordan d91bfc50fa
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: handle missing Redis credentials and redis.Nil in provisioner
Two bugs fixed:

1. redis.Nil not handled in GetProjectCache: When ACL GETUSER returns nil
   (user doesn't exist), go-redis represents this as redis.Nil error. The
   provisioner only checked for err.Contains("User") which didn't match,
   causing spurious "get ACL user: redis: nil" errors on re-provision.

2. provisionRedis returns 409 even when REDIS_URL not in credential store:
   If the Redis ACL user exists but REDIS_URL was never stored (e.g., due
   to a failed previous run or lost state), the service would permanently
   refuse to provision, leaving the project without usable Redis credentials.
   Now checks the credential store: if REDIS_URL exists → true 409 duplicate;
   if REDIS_URL missing → re-provision (CreateProjectCache resets the password).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 05:19:00 -07:00

269 lines
7.3 KiB
Go

// Package redis provides Redis cache provisioning for projects.
// Uses Redis ACLs to isolate each project to its own key prefix.
package redis
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/orchard9/rdev/internal/domain"
)
// Provisioner implements port.CacheProvisioner using Redis ACLs.
type Provisioner struct {
client *redis.Client
host string
port int
keyPrefix string
logger *slog.Logger
}
// Config holds Redis provisioner configuration.
type Config struct {
Host string
Port int
Password string
KeyPrefix string // Base prefix for project keys, default "project:"
}
// NewProvisioner creates a new Redis cache provisioner.
func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) {
if cfg.KeyPrefix == "" {
cfg.KeyPrefix = "project:"
}
client := redis.NewClient(&redis.Options{
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
Password: cfg.Password,
DB: 0,
})
// Verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis connection failed: %w", err)
}
return &Provisioner{
client: client,
host: cfg.Host,
port: cfg.Port,
keyPrefix: cfg.KeyPrefix,
logger: logger,
}, nil
}
// CreateProjectCache provisions isolated cache access for a project.
func (p *Provisioner) CreateProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) {
username := p.usernameFor(projectID)
password, err := generateToken(32)
if err != nil {
return nil, fmt.Errorf("generate password: %w", err)
}
prefix := p.prefixFor(projectID)
// Check if user already exists
existing, err := p.client.Do(ctx, "ACL", "GETUSER", username).Result()
if err == nil && existing != nil {
p.logger.Warn("cache user already exists, recreating",
"project_id", projectID,
"username", username)
// Delete existing user to recreate with new password
if err := p.client.Do(ctx, "ACL", "DELUSER", username).Err(); err != nil {
return nil, fmt.Errorf("delete existing user: %w", err)
}
}
// Create ACL user with scoped permissions:
// - on: user is active
// - >password: set password
// - ~prefix*: can only access keys matching this pattern
// - +@all: allow all command categories
// - -@dangerous: deny dangerous commands (FLUSHALL, SHUTDOWN, DEBUG, etc.)
// - -@admin: deny admin commands (CONFIG, ACL, SLAVEOF, etc.)
err = p.client.Do(ctx,
"ACL", "SETUSER", username,
"on",
">"+password,
"~"+prefix+"*",
"+@all",
"-@dangerous",
"-@admin",
).Err()
if err != nil {
return nil, fmt.Errorf("create ACL user: %w", err)
}
// Persist ACL changes to disk
if err := p.client.Do(ctx, "ACL", "SAVE").Err(); err != nil {
p.logger.Warn("failed to persist ACL to disk", "error", err)
// Non-fatal: ACLs will still work until Redis restarts
}
p.logger.Info("created project cache",
"project_id", projectID,
"username", username,
"prefix", prefix)
url := fmt.Sprintf("redis://%s:%s@%s", username, password, net.JoinHostPort(p.host, strconv.Itoa(p.port)))
return &domain.CacheCredentials{
ProjectID: projectID,
URL: url,
URLStaging: url, // Same for now; separate staging instance in future
Prefix: prefix,
Username: username,
Host: p.host,
Port: p.port,
CreatedAt: time.Now().UTC(),
}, nil
}
// DeleteProjectCache removes cache access for a project.
func (p *Provisioner) DeleteProjectCache(ctx context.Context, projectID string, purgeKeys bool) error {
username := p.usernameFor(projectID)
prefix := p.prefixFor(projectID)
// Delete ACL user
result, err := p.client.Do(ctx, "ACL", "DELUSER", username).Result()
if err != nil {
return fmt.Errorf("delete ACL user: %w", err)
}
// ACL DELUSER returns number of users deleted
deleted, ok := result.(int64)
if !ok || deleted == 0 {
p.logger.Warn("cache user did not exist", "project_id", projectID, "username", username)
}
// Optionally purge all project keys
if purgeKeys {
if err := p.purgeKeys(ctx, prefix); err != nil {
p.logger.Warn("failed to purge project keys",
"project_id", projectID,
"prefix", prefix,
"error", err)
// Non-fatal: user is already deleted
}
}
// Persist ACL changes
if err := p.client.Do(ctx, "ACL", "SAVE").Err(); err != nil {
p.logger.Warn("failed to persist ACL to disk", "error", err)
}
p.logger.Info("deleted project cache",
"project_id", projectID,
"username", username,
"purged_keys", purgeKeys)
return nil
}
// GetProjectCache retrieves cache credentials for a project.
// Note: Password cannot be retrieved from Redis ACL, only verified.
// Returns nil if user doesn't exist.
func (p *Provisioner) GetProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) {
username := p.usernameFor(projectID)
prefix := p.prefixFor(projectID)
// Check if user exists
result, err := p.client.Do(ctx, "ACL", "GETUSER", username).Result()
if err != nil {
// redis.Nil means the user does not exist (RESP null bulk string response)
if err == redis.Nil || strings.Contains(err.Error(), "User") {
return nil, nil // User doesn't exist
}
return nil, fmt.Errorf("get ACL user: %w", err)
}
if result == nil {
return nil, nil
}
// User exists but we can't retrieve password
// Caller should use stored credentials from credential store
return &domain.CacheCredentials{
ProjectID: projectID,
URL: "", // Password not available
Prefix: prefix,
Username: username,
Host: p.host,
Port: p.port,
}, nil
}
// TestConnection verifies Redis connectivity.
func (p *Provisioner) TestConnection(ctx context.Context) error {
return p.client.Ping(ctx).Err()
}
// Close closes the Redis connection.
func (p *Provisioner) Close() error {
return p.client.Close()
}
// usernameFor returns the Redis username for a project.
func (p *Provisioner) usernameFor(projectID string) string {
// Sanitize project ID for Redis username (alphanumeric + hyphen)
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return '-'
}, projectID)
return "proj-" + safe
}
// prefixFor returns the key prefix for a project.
func (p *Provisioner) prefixFor(projectID string) string {
return p.keyPrefix + projectID + ":"
}
// purgeKeys deletes all keys matching the project prefix.
func (p *Provisioner) purgeKeys(ctx context.Context, prefix string) error {
var cursor uint64
var deleted int64
for {
keys, nextCursor, err := p.client.Scan(ctx, cursor, prefix+"*", 100).Result()
if err != nil {
return fmt.Errorf("scan keys: %w", err)
}
if len(keys) > 0 {
n, err := p.client.Del(ctx, keys...).Result()
if err != nil {
return fmt.Errorf("delete keys: %w", err)
}
deleted += n
}
cursor = nextCursor
if cursor == 0 {
break
}
}
p.logger.Debug("purged project keys", "prefix", prefix, "count", deleted)
return nil
}
// 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
}