rdev/internal/adapter/postgres/webhook.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

345 lines
9.2 KiB
Go

// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// WebhookRepository implements port.WebhookRepository using PostgreSQL.
type WebhookRepository struct {
db *sql.DB
}
// NewWebhookRepository creates a new PostgreSQL webhook repository.
func NewWebhookRepository(db *sql.DB) *WebhookRepository {
return &WebhookRepository{db: db}
}
// Ensure WebhookRepository implements port.WebhookRepository at compile time.
var _ port.WebhookRepository = (*WebhookRepository)(nil)
// Create creates a new webhook subscription.
func (r *WebhookRepository) Create(ctx context.Context, webhook *domain.Webhook) error {
eventsJSON, err := json.Marshal(webhook.Events)
if err != nil {
return fmt.Errorf("marshal events: %w", err)
}
err = r.db.QueryRowContext(ctx, `
INSERT INTO webhooks (id, project_id, url, secret, events, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING created_at, updated_at
`, webhook.ID, webhook.ProjectID, webhook.URL, nullString(webhook.Secret),
string(eventsJSON), webhook.Enabled, time.Now()).Scan(&webhook.CreatedAt, &webhook.UpdatedAt)
if err != nil {
return fmt.Errorf("create webhook: %w", err)
}
return nil
}
// Update updates an existing webhook.
func (r *WebhookRepository) Update(ctx context.Context, webhook *domain.Webhook) error {
eventsJSON, err := json.Marshal(webhook.Events)
if err != nil {
return fmt.Errorf("marshal events: %w", err)
}
now := time.Now()
result, err := r.db.ExecContext(ctx, `
UPDATE webhooks
SET url = $1, secret = $2, events = $3, enabled = $4, updated_at = $5
WHERE id = $6
`, webhook.URL, nullString(webhook.Secret), string(eventsJSON), webhook.Enabled, now, webhook.ID)
if err != nil {
return fmt.Errorf("update webhook: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrWebhookNotFound
}
webhook.UpdatedAt = now
return nil
}
// Delete deletes a webhook by ID.
func (r *WebhookRepository) Delete(ctx context.Context, id domain.WebhookID) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete webhook: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrWebhookNotFound
}
return nil
}
// GetByID retrieves a webhook by ID.
func (r *WebhookRepository) GetByID(ctx context.Context, id domain.WebhookID) (*domain.Webhook, error) {
var webhook domain.Webhook
var webhookID string
var secret sql.NullString
var eventsJSON string
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, url, secret, events, enabled, created_at, updated_at
FROM webhooks
WHERE id = $1
`, id).Scan(
&webhookID,
&webhook.ProjectID,
&webhook.URL,
&secret,
&eventsJSON,
&webhook.Enabled,
&webhook.CreatedAt,
&webhook.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrWebhookNotFound
}
if err != nil {
return nil, fmt.Errorf("get webhook: %w", err)
}
webhook.ID = domain.WebhookID(webhookID)
if secret.Valid {
webhook.Secret = secret.String
}
if err := json.Unmarshal([]byte(eventsJSON), &webhook.Events); err != nil {
return nil, fmt.Errorf("unmarshal events: %w", err)
}
return &webhook, nil
}
// ListByProject returns all webhooks for a project.
func (r *WebhookRepository) ListByProject(ctx context.Context, projectID string) ([]*domain.Webhook, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, url, secret, events, enabled, created_at, updated_at
FROM webhooks
WHERE project_id = $1
ORDER BY created_at DESC
`, projectID)
if err != nil {
return nil, fmt.Errorf("list webhooks: %w", err)
}
defer func() { _ = rows.Close() }()
return scanWebhooks(rows)
}
// ListEnabledByProjectAndEvent returns enabled webhooks that subscribe to a specific event type.
func (r *WebhookRepository) ListEnabledByProjectAndEvent(ctx context.Context, projectID string, eventType domain.WebhookEventType) ([]*domain.Webhook, error) {
// Use JSON contains check - events column contains the event type
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, url, secret, events, enabled, created_at, updated_at
FROM webhooks
WHERE project_id = $1
AND enabled = true
AND events::jsonb ? $2
ORDER BY created_at ASC
`, projectID, string(eventType))
if err != nil {
return nil, fmt.Errorf("list enabled webhooks: %w", err)
}
defer func() { _ = rows.Close() }()
return scanWebhooks(rows)
}
// scanWebhooks scans rows into a slice of webhooks.
func scanWebhooks(rows *sql.Rows) ([]*domain.Webhook, error) {
var webhooks []*domain.Webhook
for rows.Next() {
var webhook domain.Webhook
var webhookID string
var secret sql.NullString
var eventsJSON string
if err := rows.Scan(
&webhookID,
&webhook.ProjectID,
&webhook.URL,
&secret,
&eventsJSON,
&webhook.Enabled,
&webhook.CreatedAt,
&webhook.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan webhook: %w", err)
}
webhook.ID = domain.WebhookID(webhookID)
if secret.Valid {
webhook.Secret = secret.String
}
if err := json.Unmarshal([]byte(eventsJSON), &webhook.Events); err != nil {
return nil, fmt.Errorf("unmarshal events: %w", err)
}
webhooks = append(webhooks, &webhook)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return webhooks, nil
}
// RecordDelivery records a webhook delivery attempt.
func (r *WebhookRepository) RecordDelivery(ctx context.Context, delivery *domain.WebhookDelivery) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO webhook_deliveries (id, webhook_id, event_type, payload, response_status, response_body, delivered_at, success, retry_count, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`,
delivery.ID,
delivery.WebhookID,
delivery.EventType,
delivery.Payload,
nullInt(delivery.ResponseStatus),
nullString(delivery.ResponseBody),
delivery.DeliveredAt,
delivery.Success,
delivery.RetryCount,
nullString(delivery.ErrorMessage),
)
if err != nil {
return fmt.Errorf("record delivery: %w", err)
}
return nil
}
// GetDeliveries returns delivery history for a webhook.
func (r *WebhookRepository) GetDeliveries(ctx context.Context, webhookID domain.WebhookID, filters *domain.WebhookDeliveryFilters) ([]*domain.WebhookDelivery, error) {
if filters == nil {
filters = domain.DefaultWebhookDeliveryFilters()
}
query := `
SELECT id, webhook_id, event_type, payload, response_status, response_body, delivered_at, success, retry_count, error_message
FROM webhook_deliveries
WHERE webhook_id = $1
`
args := []any{webhookID}
argNum := 2
if filters.EventType != nil {
query += fmt.Sprintf(" AND event_type = $%d", argNum)
args = append(args, string(*filters.EventType))
argNum++
}
if filters.Success != nil {
query += fmt.Sprintf(" AND success = $%d", argNum)
args = append(args, *filters.Success)
argNum++
}
query += " ORDER BY delivered_at DESC"
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argNum, argNum+1)
args = append(args, filters.Limit, filters.Offset)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get deliveries: %w", err)
}
defer func() { _ = rows.Close() }()
var deliveries []*domain.WebhookDelivery
for rows.Next() {
var delivery domain.WebhookDelivery
var deliveryID, webhookIDStr string
var eventType string
var responseStatus sql.NullInt32
var responseBody, errorMessage sql.NullString
if err := rows.Scan(
&deliveryID,
&webhookIDStr,
&eventType,
&delivery.Payload,
&responseStatus,
&responseBody,
&delivery.DeliveredAt,
&delivery.Success,
&delivery.RetryCount,
&errorMessage,
); err != nil {
return nil, fmt.Errorf("scan delivery: %w", err)
}
delivery.ID = domain.WebhookDeliveryID(deliveryID)
delivery.WebhookID = domain.WebhookID(webhookIDStr)
delivery.EventType = domain.WebhookEventType(eventType)
if responseStatus.Valid {
delivery.ResponseStatus = int(responseStatus.Int32)
}
if responseBody.Valid {
delivery.ResponseBody = responseBody.String
}
if errorMessage.Valid {
delivery.ErrorMessage = errorMessage.String
}
deliveries = append(deliveries, &delivery)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
return deliveries, nil
}
// CleanupOldDeliveries removes delivery records older than the specified number of days.
func (r *WebhookRepository) CleanupOldDeliveries(ctx context.Context, olderThanDays int) (int64, error) {
result, err := r.db.ExecContext(ctx, `
DELETE FROM webhook_deliveries
WHERE delivered_at < NOW() - INTERVAL '1 day' * $1
`, olderThanDays)
if err != nil {
return 0, fmt.Errorf("cleanup old deliveries: %w", err)
}
return result.RowsAffected()
}
// nullInt returns a sql.NullInt32 for optional int fields.
func nullInt(i int) sql.NullInt32 {
if i == 0 {
return sql.NullInt32{}
}
return sql.NullInt32{Int32: int32(i), Valid: true}
}