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>
268 lines
7.2 KiB
Go
268 lines
7.2 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 {
|
|
if 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
|
|
}
|