From 544a0e9bc6447e4ce5f59a3db0eaccfe7cac80a0 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Mon, 1 Jun 2026 16:44:50 +0530 Subject: [PATCH 01/19] feat: centralise config singleton, replace logger with pkg/logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates all scattered os.Getenv calls outside internal/config and upgrades the logger to a structured, OTel-wired pkg/logger package. Config changes: - Expand EnvConfig with OtelConfig, StorageConfig, CORS origins, EncryptionKey, LogLevel, AutoMigrate, MaxAssetSizeBytes - Add sync.Once singleton (Init / MustGet) — all internal packages read from config.MustGet() instead of os.Getenv - All required vars validated at startup; ENCRYPTION_KEY validated for exact 32-byte length - .env.example documents every var across Go and Python sides Logger changes: - New pkg/logger package: New(), prettyCore (dev), JSON (prod), OTel bridge via otelzap, context helpers, trace annotation, Sync() - Drop pkg/utils/logger.go; all callers migrated to *zap.Logger - crypt.go: GenerateToken/DecryptToken accept key param instead of reading env — pkg/utils stays free of internal/config coupling Python worker: - Add OtelConfig dataclass and missing fields (auto_migrate, migrations_dir, log_level) to WorkerConfig - Add get_config() / init_config() lazy singleton - worker/utils/metrics.py: init_metrics accepts all OTel params explicitly instead of reading env internally Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 80 +++++++ cmd/server/main.go | 44 ++-- go.mod | 15 +- go.sum | 14 ++ internal/config/env.go | 151 ++++++++++-- internal/database/pg.go | 26 ++- internal/handler/asset_handler.go | 114 ++++----- internal/metrics/metrics.go | 337 ++++++++++----------------- internal/metrics/otel.go | 151 ++++-------- internal/middleware/authorization.go | 18 +- internal/middleware/logging.go | 72 +++--- internal/middleware/recovery.go | 9 +- internal/middleware/slow_request.go | 9 +- internal/queue/queue.go | 40 ++-- internal/queue/redis.go | 10 +- internal/repository/asset_repo.go | 55 ++--- internal/router/router.go | 113 ++++++--- internal/server/server.go | 20 +- internal/service/asset.go | 38 ++- pkg/logger/config.go | 23 ++ pkg/logger/context.go | 20 ++ pkg/logger/helpers.go | 7 + pkg/logger/logger.go | 78 +++++++ pkg/logger/prettycore.go | 98 ++++++++ pkg/logger/sync.go | 18 ++ pkg/logger/trace.go | 25 ++ pkg/utils/crypt.go | 33 ++- pkg/utils/storagex/gcs.go | 40 ++-- worker/consumer/config.py | 80 ++++++- worker/consumer/main.py | 19 +- worker/utils/metrics.py | 36 +-- 31 files changed, 1069 insertions(+), 724 deletions(-) create mode 100644 .env.example create mode 100644 pkg/logger/config.go create mode 100644 pkg/logger/context.go create mode 100644 pkg/logger/helpers.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/prettycore.go create mode 100644 pkg/logger/sync.go create mode 100644 pkg/logger/trace.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f745b30 --- /dev/null +++ b/.env.example @@ -0,0 +1,80 @@ +# ─── Runtime ────────────────────────────────────────────────────────────────── +# Current environment: development | staging | production +ENV=development + +# ─── Server ─────────────────────────────────────────────────────────────────── +HOST=0.0.0.0 +PORT=5010 + +# ─── Database ───────────────────────────────────────────────────────────────── +DB_HOST=localhost +DB_PORT=5432 +DB_USER=mpiper +DB_PASSWORD=changeme +DB_NAME=mpiper +# DB_SSL_MODE defaults to "disable" in the API server +# DB_SSL_MODE=require +# DB_SSL_CERT_PATH=/path/to/cert.pem # Python worker only + +# Connection pool (Python worker) +DB_POOL_SIZE=10 +DB_POOL_TIMEOUT=30 +DB_MAX_RETRIES=5 +DB_RETRY_DELAY=5 + +# ─── Redis ──────────────────────────────────────────────────────────────────── +REDIS_CONNECTION_STRING=redis://localhost:6379/0 + +# Connection pool (Python worker) +REDIS_POOL_SIZE=10 +REDIS_POOL_TIMEOUT=30 +REDIS_MAX_RETRIES=5 +REDIS_RETRY_DELAY=5 +REDIS_CONNECT_TIMEOUT=10 +REDIS_READ_TIMEOUT=10 +REDIS_WRITE_TIMEOUT=10 + +# ─── Security ───────────────────────────────────────────────────────────────── +# AES-256 key — must be exactly 32 characters +ENCRYPTION_KEY=LKyGslR3InLES/EYQiJZcW06KFNMoevUd6kehjtrxPA= + +# ─── Storage / GCS ──────────────────────────────────────────────────────────── +BUCKET_PROVIDER=gcs +BUCKET_NAME=my-media-bucket +BUCKET_REGION=us-east-1 +BUCKET_ACCESS_KEY= +BUCKET_SECRET_KEY= +BUCKET_ENDPOINT_URL= +# Path to GCS service-account JSON key file +GCS_SA_PATH=/path/to/service-account.json +BUCKET_SA_PATH=/path/to/service-account.json + +# ─── OpenTelemetry ──────────────────────────────────────────────────────────── +OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 +# Set to "true" for local/dev collectors that don't use TLS +OTEL_TLS_INSECURE=true +DEPLOYMENT_ENV=development +TRACE_SAMPLING_RATE=0.1 +SERVICE_NAME=mpiper-api +SERVICE_VERSION=dev + +# ─── CORS ───────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed origins (defaults to http://localhost:5173) +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# ─── Logging ────────────────────────────────────────────────────────────────── +# DEBUG | INFO | WARN | ERROR | FATAL +LOG_LEVEL=INFO + +# ─── Migrations ─────────────────────────────────────────────────────────────── +# Set to "true" to run DB migrations on startup +AUTO_MIGRATE=false +# Python worker: override default migrations directory +# MIGRATIONS_DIR=/path/to/migrations + +# ─── Worker ─────────────────────────────────────────────────────────────────── +WORKER_ID= +MAX_CONCURRENT_JOBS=5 +JOB_POLL_INTERVAL=10 +STREAM_NAME=media:jobs +CONSUMER_GROUP=worker-group diff --git a/cmd/server/main.go b/cmd/server/main.go index f61b6b2..49fb411 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,7 +14,7 @@ import ( "github.com/rndmcodeguy20/mpiper/internal/database" "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/internal/server" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + "github.com/rndmcodeguy20/mpiper/pkg/logger" "go.uber.org/zap" ) @@ -31,41 +31,42 @@ func main() { if err != nil { panic(err) } + config.Init(cfg) serverCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - baseLogger := utils.NewLogger().With( + baseLogger := logger.New(logger.Config{ + ServiceName: cfg.Otel.ServiceName, + Environment: cfg.Environment, + Level: logger.ParseLevel(cfg.LogLevel), + EnableOTel: true, + }).With( zap.String("version", Version), zap.String("commit_hash", CommitHash), zap.String("build_time", BuildTime), zap.String("author", Author), ) - defer func(l *utils.Logger) { - err := l.Close() - if err != nil { + defer func() { + if err := logger.Sync(baseLogger); err != nil { panic(err) } - }(baseLogger) + }() baseLogger.Sugar().Infof("Starting %s server on https://%s:%d in %s mode", "MPiper", cfg.Server.Host, cfg.Server.Port, cfg.Environment) - // Initialize tracer tracerCtx := serverCtx - shutdownTracer := metrics.InitTracer(tracerCtx, *baseLogger) + shutdownTracer := metrics.InitTracer(tracerCtx, baseLogger) defer func() { - err := shutdownTracer(tracerCtx) - if err != nil { + if err := shutdownTracer(tracerCtx); err != nil { baseLogger.Sugar().Errorf("Failed to shut down tracer: %v", err) } }() - // Initialize metrics metricsCtx := serverCtx - shutdownMetrics := metrics.InitMetrics(metricsCtx, *baseLogger) + m, shutdownMetrics := metrics.InitMetrics(metricsCtx, baseLogger) defer func() { - err := shutdownMetrics(metricsCtx) - if err != nil { + if err := shutdownMetrics(metricsCtx); err != nil { baseLogger.Sugar().Errorf("Failed to shut down metrics: %v", err) } }() @@ -75,16 +76,23 @@ func main() { baseLogger.Sugar().Fatalf("Failed to connect to database: %v", err) } defer func(db *sqlx.DB) { - err := db.Close() - if err != nil { + if err := db.Close(); err != nil { baseLogger.Sugar().Errorf("Failed to close database connection: %v", err) } }(db) - srv := server.NewServer(db, cfg) + if cfg.AutoMigrate { + baseLogger.Info("AUTO_MIGRATE=true: running migrations") + if err := database.RunMigrations(db.DB); err != nil { + baseLogger.Sugar().Fatalf("Migration failed: %v", err) + } + baseLogger.Info("Migrations applied successfully") + } + + srv := server.NewServer(db, cfg, m) go func() { if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { - baseLogger.Fatal("Server error: ", zap.Error(err)) + baseLogger.Fatal("Server error", zap.Error(err)) } }() diff --git a/go.mod b/go.mod index 471b2d3..0611d2f 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/rndmcodeguy20/mpiper -go 1.24.7 - -toolchain go1.24.10 +go 1.25.0 require ( cloud.google.com/go/storage v1.58.0 @@ -13,14 +11,14 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.17.2 - go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 - go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/sdk/metric v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 - go.uber.org/zap v1.27.1 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/zap v1.28.0 golang.org/x/crypto v0.45.0 google.golang.org/api v0.256.0 google.golang.org/grpc v1.77.0 @@ -47,6 +45,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -54,10 +53,12 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.47.0 // indirect diff --git a/go.sum b/go.sum index fd0570e..5b08213 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -103,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 h1:48Eq3xxFx2KlL/tF7lnl42kKJBDlhNTLRzv0h154JnM= +go.opentelemetry.io/contrib/bridges/otelzap v0.19.0/go.mod h1:cQbV77F0u6HmtZPiQD9oxp2esaOEb4uLqIta6OFIKOk= go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= @@ -111,6 +115,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= @@ -119,14 +125,20 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -135,6 +147,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/internal/config/env.go b/internal/config/env.go index 336099e..2179c94 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -1,8 +1,11 @@ package config import ( + "fmt" "os" "strconv" + "strings" + "sync" "time" "github.com/joho/godotenv" @@ -45,75 +48,177 @@ type RedisConfig struct { WriteTimeout time.Duration } +type OtelConfig struct { + Endpoint string + TLSInsecure bool + DeploymentEnv string + TraceSamplingRate float64 + ServiceName string + ServiceVersion string +} + +type GCSConfig struct { + SAPath string +} + +type StorageConfig struct { + Provider string + GCS GCSConfig +} + type EnvConfig struct { - Environment string - Server ServerConfig - DB DatabaseConfig - Redis RedisConfig + Environment string + Server ServerConfig + DB DatabaseConfig + Redis RedisConfig + Otel OtelConfig + Storage StorageConfig + CORSAllowedOrigins []string + LogLevel string + EncryptionKey string + AutoMigrate bool + MaxAssetSizeBytes int64 } -func GetEnvConfig(envFile string) (EnvConfig, error) { - _ = godotenv.Load(envFile) // Load .env file if it exists +// --- Singleton --- + +var ( + instance *EnvConfig + once sync.Once +) + +// Init stores cfg as the package-level singleton. Must be called once at startup before MustGet. +func Init(cfg EnvConfig) { + once.Do(func() { instance = &cfg }) +} - host := os.Getenv("HOST") - if host == "" { - host = "0.0.0.0" +// MustGet returns the singleton config. Panics if Init has not been called. +func MustGet() *EnvConfig { + if instance == nil { + panic("config: MustGet called before Init — call config.Init(cfg) at startup") } + return instance +} + +// --- Loading --- + +func GetEnvConfig(envFile string) (EnvConfig, error) { + _ = godotenv.Load(envFile) + + host := envOr("HOST", "0.0.0.0") + port, err := strconv.Atoi(os.Getenv("PORT")) if err != nil { - port = 5010 // default port + port = 5010 } - dbHost := os.Getenv("DB_HOST") + dbPort, err := strconv.Atoi(os.Getenv("DB_PORT")) if err != nil { - dbPort = 5432 // default DB port + dbPort = 5432 } - dbUser := os.Getenv("DB_USER") - dbPassword := os.Getenv("DB_PASSWORD") - dbName := os.Getenv("DB_NAME") - env := os.Getenv("ENV") - connectionString := os.Getenv("REDIS_CONNECTION_STRING") + env := os.Getenv("ENV") if env == "" { return EnvConfig{}, NewInitializationError("ENV is not set", nil) } + dbUser := os.Getenv("DB_USER") if dbUser == "" { return EnvConfig{}, NewInitializationError("DB_USER is not set", nil) } + dbPassword := os.Getenv("DB_PASSWORD") if dbPassword == "" { return EnvConfig{}, NewInitializationError("DB_PASSWORD is not set", nil) } + dbName := os.Getenv("DB_NAME") if dbName == "" { return EnvConfig{}, NewInitializationError("DB_NAME is not set", nil) } - if connectionString == "" { - return EnvConfig{}, NewInitializationError("REDIS_URL is not set", nil) + redisConnStr := os.Getenv("REDIS_CONNECTION_STRING") + if redisConnStr == "" { + return EnvConfig{}, NewInitializationError("REDIS_CONNECTION_STRING is not set", nil) + } + + encryptionKey := os.Getenv("ENCRYPTION_KEY") + if encryptionKey == "" { + return EnvConfig{}, NewInitializationError("ENCRYPTION_KEY is not set", nil) + } + if len(encryptionKey) != 32 { + return EnvConfig{}, NewInitializationError( + fmt.Sprintf("ENCRYPTION_KEY must be exactly 32 bytes for AES-256, got %d", len(encryptionKey)), nil, + ) + } + + traceSamplingRate := 0.1 + if raw := os.Getenv("TRACE_SAMPLING_RATE"); raw != "" { + if parsed, err := strconv.ParseFloat(raw, 64); err == nil { + traceSamplingRate = parsed + } + } + + maxAssetSize := int64(500 * 1024 * 1024) + if raw := os.Getenv("MAX_ASSET_SIZE_BYTES"); raw != "" { + if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 { + maxAssetSize = n + } + } + + corsOrigins := []string{"http://localhost:5173"} + if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" { + corsOrigins = strings.Split(raw, ",") } return EnvConfig{ Environment: env, Server: ServerConfig{ - Port: port, // default port + Port: port, Host: host, }, DB: DatabaseConfig{ - Host: dbHost, - Port: dbPort, // default DB port + Host: envOr("DB_HOST", "localhost"), + Port: dbPort, User: dbUser, Password: dbPassword, Name: dbName, SSLMode: "disable", }, Redis: RedisConfig{ - ConnectionString: connectionString, + ConnectionString: redisConnStr, + }, + Otel: OtelConfig{ + Endpoint: envOr("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"), + TLSInsecure: strings.ToLower(os.Getenv("OTEL_TLS_INSECURE")) == "true", + DeploymentEnv: envOr("DEPLOYMENT_ENV", env), + TraceSamplingRate: traceSamplingRate, + ServiceName: envOr("SERVICE_NAME", "mpiper-api"), + ServiceVersion: envOr("SERVICE_VERSION", "dev"), }, + Storage: StorageConfig{ + Provider: envOr("BUCKET_PROVIDER", "gcs"), + GCS: GCSConfig{ + SAPath: os.Getenv("GCS_SA_PATH"), + }, + }, + CORSAllowedOrigins: corsOrigins, + LogLevel: envOr("LOG_LEVEL", "INFO"), + EncryptionKey: encryptionKey, + AutoMigrate: strings.ToLower(os.Getenv("AUTO_MIGRATE")) == "true", + MaxAssetSizeBytes: maxAssetSize, }, nil } +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// --- Environment type helpers --- + type Environment string const ( diff --git a/internal/database/pg.go b/internal/database/pg.go index 2631772..57b0a93 100644 --- a/internal/database/pg.go +++ b/internal/database/pg.go @@ -6,33 +6,37 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/rndmcodeguy20/mpiper/internal/config" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + applogger "github.com/rndmcodeguy20/mpiper/pkg/logger" ) -// NewPostgresDB creates a new PostgresSQL database connection +// NewPostgresDB creates a new PostgreSQL database connection. func NewPostgresDB(databaseConfig config.DatabaseConfig) (*sqlx.DB, error) { - pgLogger := utils.NewLogger() + cfg := config.MustGet() + l := applogger.New(applogger.Config{ + ServiceName: cfg.Otel.ServiceName, + Environment: cfg.Environment, + Level: applogger.ParseLevel(cfg.LogLevel), + }) + dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - databaseConfig.Host, databaseConfig.Port, databaseConfig.User, databaseConfig.Password, databaseConfig.Name, databaseConfig.SSLMode, + databaseConfig.Host, databaseConfig.Port, databaseConfig.User, + databaseConfig.Password, databaseConfig.Name, databaseConfig.SSLMode, ) - pgLogger.Sugar().Infof("Connecting to database: %s", databaseConfig.Name) + l.Sugar().Infof("Connecting to database: %s", databaseConfig.Name) db, err := sqlx.Connect("postgres", dsn) if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } - pgLogger.Info("Connected to database successfully") + l.Info("Connected to database successfully") - // Configure connection pool db.SetMaxOpenConns(25) db.SetMaxIdleConns(25) - db.SetConnMaxLifetime(300) // 5 minutes - - // Set the connection timeout - db.SetConnMaxIdleTime(30) // 30 seconds + db.SetConnMaxLifetime(300) + db.SetConnMaxIdleTime(30) if err := db.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) diff --git a/internal/handler/asset_handler.go b/internal/handler/asset_handler.go index 4541066..1473230 100644 --- a/internal/handler/asset_handler.go +++ b/internal/handler/asset_handler.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/rndmcodeguy20/mpiper/internal/config" "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/internal/models" "github.com/rndmcodeguy20/mpiper/internal/service" @@ -16,16 +17,26 @@ import ( "go.uber.org/zap" ) +var allowedMIMETypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "video/mp4": true, + "video/quicktime": true, +} + +func maxAssetSize() int64 { + return config.MustGet().MaxAssetSizeBytes +} + type AssetHandler struct { svc service.AssetService - logger *utils.Logger + logger *zap.Logger + m *metrics.Metrics } -func NewAssetHandler(svc service.AssetService, logger *utils.Logger) *AssetHandler { - return &AssetHandler{ - svc: svc, - logger: logger, - } +func NewAssetHandler(svc service.AssetService, logger *zap.Logger, m *metrics.Metrics) *AssetHandler { + return &AssetHandler{svc: svc, logger: logger, m: m} } func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { @@ -38,15 +49,6 @@ func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { timeoutCtx, cancelFn := utils.GetTimeoutContext(ctx, 30) defer cancelFn() - // Record request size - //if r.ContentLength > 0 && metrics.HTTPRequestSize != nil { - // attrs := []attribute.KeyValue{ - // attribute.String("http.method", r.Method), - // attribute.String("http.route", r.URL.Path), - // } - // metrics.HTTPRequestSize.Record(ctx, r.ContentLength, metric.WithAttributes(attrs...)) - //} - span.SetAttributes( attribute.String("http.method", r.Method), attribute.String("http.route", chi.RouteContext(r.Context()).RoutePattern()), @@ -54,22 +56,21 @@ func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { start := time.Now() defer func() { - metrics.AssetUploadDuration.Record(ctx, time.Since(start).Seconds()) + if h.m != nil { + h.m.AssetUploadDuration.Record(ctx, time.Since(start).Seconds()) + } }() - metrics.AssetUploadTotal.Add(ctx, 1) + if h.m != nil { + h.m.AssetUploadTotal.Add(ctx, 1) + } var req models.UploadAssetRequest - err := utils.ParseJSON(r.Body, &req) - if err != nil { + if err := utils.ParseJSON(r.Body, &req); err != nil { h.logger.Error("Failed to parse create asset request", zap.Error(err)) span.RecordError(err) span.SetStatus(codes.Error, "Invalid request payload") - utils.RespondJSON( - w, - map[string]string{"status": "error", "message": "Invalid request payload"}, - http.StatusBadRequest, - ) + utils.RespondJSON(w, map[string]string{"status": "error", "message": "Invalid request payload"}, http.StatusBadRequest) return } @@ -79,13 +80,20 @@ func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { ) if req.ContentType == "" { - h.logger.Error("ContentType is required") span.SetStatus(codes.Error, "ContentType is required") - utils.RespondJSON( - w, - map[string]string{"status": "error", "message": "ContentType is required"}, - http.StatusBadRequest, - ) + utils.RespondJSON(w, map[string]string{"status": "error", "message": "ContentType is required"}, http.StatusBadRequest) + return + } + + if !allowedMIMETypes[req.ContentType] { + span.SetStatus(codes.Error, "unsupported content type") + utils.RespondJSON(w, map[string]string{"status": "error", "message": "unsupported content type"}, http.StatusBadRequest) + return + } + + if req.Size > maxAssetSize() { + span.SetStatus(codes.Error, "file too large") + utils.RespondJSON(w, map[string]string{"status": "error", "message": "file exceeds maximum allowed size"}, http.StatusBadRequest) return } @@ -96,41 +104,29 @@ func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { } res, err := h.svc.CreateAsset(timeoutCtx, req) - if err != nil { h.logger.Error("Failed to create asset", zap.Error(err)) span.RecordError(err) span.SetStatus(codes.Error, "Failed to create asset") - metrics.AssetProcessingFailed.Add(timeoutCtx, 1) - utils.RespondJSON( - w, - map[string]string{"status": "error", "message": "Failed to create asset", "error": err.Error()}, - http.StatusInternalServerError, - ) + if h.m != nil { + h.m.AssetProcessingFailed.Add(timeoutCtx, 1) + } + utils.RespondJSON(w, map[string]string{"status": "error", "message": "Failed to create asset", "error": err.Error()}, http.StatusInternalServerError) return } if res == nil { - h.logger.Error("CreateAsset returned nil response without error") span.SetStatus(codes.Error, "Nil response from CreateAsset") - metrics.AssetProcessingFailed.Add(timeoutCtx, 1) - utils.RespondJSON( - w, - map[string]string{"status": "error", "message": "Internal server error: nil response from CreateAsset"}, - http.StatusInternalServerError, - ) + if h.m != nil { + h.m.AssetProcessingFailed.Add(timeoutCtx, 1) + } + utils.RespondJSON(w, map[string]string{"status": "error", "message": "Internal server error"}, http.StatusInternalServerError) return } h.logger.Sugar().Infof("Asset created: %s", res.AssetID) - span.SetAttributes(attribute.String("asset_id", res.AssetID)) span.SetStatus(codes.Ok, "Asset created successfully") - - utils.RespondJSON( - w, - map[string]interface{}{"status": "success", "data": res}, - http.StatusOK, - ) + utils.RespondJSON(w, map[string]interface{}{"status": "success", "data": res}, http.StatusOK) } func (h *AssetHandler) MarkAssetUploaded(w http.ResponseWriter, r *http.Request) { @@ -157,23 +153,15 @@ func (h *AssetHandler) MarkAssetUploaded(w http.ResponseWriter, r *http.Request) utils.RespondJSON(w, map[string]string{"status": "error", "message": "invalid asset id"}, http.StatusBadRequest) return } - err = h.svc.MarkAssetUploaded(timeoutCtx, parsedID) - if err != nil { + + if err := h.svc.MarkAssetUploaded(timeoutCtx, parsedID); err != nil { h.logger.Sugar().Errorf("Failed to mark asset uploaded: %v", err) span.RecordError(err) span.SetStatus(codes.Error, "Failed to mark asset uploaded") - utils.RespondJSON( - w, - map[string]string{"status": "error", "message": "Failed to mark asset uploaded", "error": err.Error()}, - http.StatusInternalServerError, - ) + utils.RespondJSON(w, map[string]string{"status": "error", "message": "Failed to mark asset uploaded", "error": err.Error()}, http.StatusInternalServerError) return } span.SetStatus(codes.Ok, "Asset marked as uploaded") - utils.RespondJSON( - w, - map[string]string{"status": "success", "message": "Asset marked as uploaded"}, - http.StatusOK, - ) + utils.RespondJSON(w, map[string]string{"status": "success", "message": "Asset marked as uploaded"}, http.StatusOK) } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index db0b9c6..4dbe838 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -5,7 +5,8 @@ import ( "runtime" "time" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + "github.com/rndmcodeguy20/mpiper/internal/config" + "go.uber.org/zap" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/metric" @@ -13,10 +14,14 @@ import ( "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.17.0" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) -var ( +// Metrics holds all OTel instruments. Pass *Metrics via DI; never use package-level globals. +type Metrics struct { + meter metric.Meter + HTTPRequestDuration metric.Float64Histogram HTTPRequestCount metric.Int64Counter HTTPRequestSize metric.Int64Histogram @@ -49,32 +54,49 @@ var ( QueueDepth metric.Int64ObservableGauge QueueProcessingLag metric.Float64Histogram - SystemCPUUsage metric.Float64ObservableGauge SystemMemoryUsage metric.Int64ObservableGauge SystemGoroutineCount metric.Int64ObservableGauge SystemGCPauseDuration metric.Float64Histogram -) +} + +// RegisterQueueDepthFunc wires an XLEN-style callback to the QueueDepth gauge. +// Call after the queue client is initialised. +func (m *Metrics) RegisterQueueDepthFunc(fn func(context.Context) (int64, error)) error { + _, err := m.meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error { + n, err := fn(ctx) + if err != nil { + return err + } + o.ObserveInt64(m.QueueDepth, n) + return nil + }, m.QueueDepth) + return err +} -func InitMetrics(ctx context.Context, logger utils.Logger) func(context.Context) error { - endpoint := getEnvOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317") - endpoint = stripURLScheme(endpoint) +func InitMetrics(ctx context.Context, logger *zap.Logger) (*Metrics, func(context.Context) error) { + otelCfg := config.MustGet().Otel + endpoint := stripURLScheme(otelCfg.Endpoint) logger.Sugar().Infof("Initializing OpenTelemetry metrics with endpoint: %s", endpoint) opts := []otlpmetricgrpc.Option{ otlpmetricgrpc.WithEndpoint(endpoint), - otlpmetricgrpc.WithInsecure(), otlpmetricgrpc.WithTimeout(10 * time.Second), otlpmetricgrpc.WithCompressor("gzip"), otlpmetricgrpc.WithDialOption( - grpc.WithDefaultCallOptions( - grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), - ), - ), - otlpmetricgrpc.WithDialOption( - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100 * 1024 * 1024)), ), } + if otelCfg.TLSInsecure { + opts = append(opts, + otlpmetricgrpc.WithInsecure(), + otlpmetricgrpc.WithDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + ) + } else { + opts = append(opts, + otlpmetricgrpc.WithDialOption(grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))), + ) + } exp, err := otlpmetricgrpc.New(ctx, opts...) if err != nil { @@ -83,9 +105,9 @@ func InitMetrics(ctx context.Context, logger utils.Logger) func(context.Context) res, err := resource.New(ctx, resource.WithAttributes( - semconv.ServiceName(getEnvOrDefault("SERVICE_NAME", "mpiper-api")), - semconv.ServiceVersion(getEnvOrDefault("SERVICE_VERSION", "dev")), - semconv.DeploymentEnvironment(getEnvOrDefault("DEPLOYMENT_ENV", "development")), + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(otelCfg.ServiceVersion), + semconv.DeploymentEnvironment(otelCfg.DeploymentEnv), semconv.ServiceInstanceID(getInstanceID()), ), resource.WithFromEnv(), @@ -99,31 +121,15 @@ func InitMetrics(ctx context.Context, logger utils.Logger) func(context.Context) res = resource.Default() } - httpLatencyBuckets := []float64{ - 0.05, // 50ms - 0.1, - 0.2, - 0.5, - 1, - 2, - 5, - 10, - } - mp := sdkmetric.NewMeterProvider( sdkmetric.WithResource(res), - sdkmetric.WithReader( - sdkmetric.NewPeriodicReader(exp), - ), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)), sdkmetric.WithView( sdkmetric.NewView( - sdkmetric.Instrument{ - Name: "http.server.request.duration", - Kind: sdkmetric.InstrumentKindHistogram, - }, + sdkmetric.Instrument{Name: "http.server.request.duration", Kind: sdkmetric.InstrumentKindHistogram}, sdkmetric.Stream{ Aggregation: sdkmetric.AggregationExplicitBucketHistogram{ - Boundaries: httpLatencyBuckets, + Boundaries: []float64{0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10}, }, }, ), @@ -133,280 +139,179 @@ func InitMetrics(ctx context.Context, logger utils.Logger) func(context.Context) otel.SetMeterProvider(mp) meter := mp.Meter("mpiper-api") - // Initialize all metrics - initHTTPMetrics(meter, logger) - initBusinessMetrics(meter, logger) - initStorageMetrics(meter, logger) - initDatabaseMetrics(meter, logger) - initQueueMetrics(meter, logger) - initSystemMetrics(meter, logger) + m := &Metrics{meter: meter} + initHTTPMetrics(m, meter, logger) + initBusinessMetrics(m, meter, logger) + initStorageMetrics(m, meter, logger) + initDatabaseMetrics(m, meter, logger) + initQueueMetrics(m, meter, logger) + initSystemMetrics(m, meter, logger) logger.Sugar().Info("OpenTelemetry metrics initialized successfully") - return mp.Shutdown + return m, mp.Shutdown } -func initHTTPMetrics(meter metric.Meter, logger utils.Logger) { +func initHTTPMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - HTTPRequestDuration, err = meter.Float64Histogram( - "http.server.request.duration", - metric.WithDescription("Duration of HTTP requests in seconds"), - metric.WithUnit("s"), - ) + m.HTTPRequestDuration, err = meter.Float64Histogram("http.server.request.duration", + metric.WithDescription("Duration of HTTP requests in seconds"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create HTTP request duration: %v", err) } - - HTTPRequestCount, err = meter.Int64Counter( - "http.server.request.count", - metric.WithDescription("Total number of HTTP requests"), - metric.WithUnit("{request}"), - ) + m.HTTPRequestCount, err = meter.Int64Counter("http.server.request.count", + metric.WithDescription("Total number of HTTP requests"), metric.WithUnit("{request}")) if err != nil { logger.Sugar().Fatalf("Failed to create HTTP request counter: %v", err) } - - HTTPRequestSize, err = meter.Int64Histogram( - "http.server.request.size", - metric.WithDescription("Size of HTTP request in bytes"), - metric.WithUnit("By"), - ) + m.HTTPRequestSize, err = meter.Int64Histogram("http.server.request.size", + metric.WithDescription("Size of HTTP request in bytes"), metric.WithUnit("By")) if err != nil { logger.Sugar().Fatalf("Failed to create HTTP request size: %v", err) } - - HTTPResponseSize, err = meter.Int64Histogram( - "http.server.response.size", - metric.WithDescription("Size of HTTP response in bytes"), - metric.WithUnit("By"), - ) + m.HTTPResponseSize, err = meter.Int64Histogram("http.server.response.size", + metric.WithDescription("Size of HTTP response in bytes"), metric.WithUnit("By")) if err != nil { logger.Sugar().Fatalf("Failed to create HTTP response size: %v", err) } - - HTTPActiveRequests, err = meter.Int64UpDownCounter( - "http.server.active_requests", - metric.WithDescription("Number of active HTTP requests"), - metric.WithUnit("{request}"), - ) + m.HTTPActiveRequests, err = meter.Int64UpDownCounter("http.server.active_requests", + metric.WithDescription("Number of active HTTP requests"), metric.WithUnit("{request}")) if err != nil { logger.Sugar().Fatalf("Failed to create active requests counter: %v", err) } } -func initBusinessMetrics(meter metric.Meter, logger utils.Logger) { +func initBusinessMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - AssetUploadTotal, err = meter.Int64Counter( - "asset.upload.total", - metric.WithDescription("Total number of asset uploads"), - metric.WithUnit("{upload}"), - ) + m.AssetUploadTotal, err = meter.Int64Counter("asset.upload.total", + metric.WithDescription("Total number of asset uploads"), metric.WithUnit("{upload}")) if err != nil { logger.Sugar().Fatalf("Failed to create asset upload counter: %v", err) } - - AssetUploadDuration, err = meter.Float64Histogram( - "asset.upload.duration", - metric.WithDescription("Duration of asset upload operations"), - metric.WithUnit("s"), - ) + m.AssetUploadDuration, err = meter.Float64Histogram("asset.upload.duration", + metric.WithDescription("Duration of asset upload operations"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create asset upload duration: %v", err) } - - AssetProcessingTotal, err = meter.Int64Counter( - "asset.processing.total", - metric.WithDescription("Total number of assets processed"), - metric.WithUnit("{asset}"), - ) + m.AssetProcessingTotal, err = meter.Int64Counter("asset.processing.total", + metric.WithDescription("Total number of assets processed"), metric.WithUnit("{asset}")) if err != nil { logger.Sugar().Fatalf("Failed to create asset processing counter: %v", err) } - - AssetProcessingSuccess, err = meter.Int64Counter( - "asset.processing.success", - metric.WithDescription("Number of successfully processed assets"), - metric.WithUnit("{asset}"), - ) + m.AssetProcessingSuccess, err = meter.Int64Counter("asset.processing.success", + metric.WithDescription("Number of successfully processed assets"), metric.WithUnit("{asset}")) if err != nil { logger.Sugar().Fatalf("Failed to create asset processing success counter: %v", err) } - - AssetProcessingFailed, err = meter.Int64Counter( - "asset.processing.failed", - metric.WithDescription("Number of failed asset processing attempts"), - metric.WithUnit("{asset}"), - ) + m.AssetProcessingFailed, err = meter.Int64Counter("asset.processing.failed", + metric.WithDescription("Number of failed asset processing attempts"), metric.WithUnit("{asset}")) if err != nil { logger.Sugar().Fatalf("Failed to create asset processing failed counter: %v", err) } - - AssetProcessingDuration, err = meter.Float64Histogram( - "asset.processing.duration", - metric.WithDescription("Duration of asset processing"), - metric.WithUnit("s"), - ) + m.AssetProcessingDuration, err = meter.Float64Histogram("asset.processing.duration", + metric.WithDescription("Duration of asset processing"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create asset processing duration: %v", err) } - - AssetSizeBytes, err = meter.Int64Histogram( - "asset.size", - metric.WithDescription("Size of assets in bytes"), - metric.WithUnit("By"), - ) + m.AssetSizeBytes, err = meter.Int64Histogram("asset.size", + metric.WithDescription("Size of assets in bytes"), metric.WithUnit("By")) if err != nil { logger.Sugar().Fatalf("Failed to create asset size histogram: %v", err) } } -func initStorageMetrics(meter metric.Meter, logger utils.Logger) { +func initStorageMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - StorageOperationDuration, err = meter.Float64Histogram( - "storage.operation.duration", - metric.WithDescription("Duration of storage operations"), - metric.WithUnit("s"), - ) + m.StorageOperationDuration, err = meter.Float64Histogram("storage.operation.duration", + metric.WithDescription("Duration of storage operations"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create storage operation duration: %v", err) } - - StorageOperationTotal, err = meter.Int64Counter( - "storage.operation.total", - metric.WithDescription("Total number of storage operations"), - metric.WithUnit("{operation}"), - ) + m.StorageOperationTotal, err = meter.Int64Counter("storage.operation.total", + metric.WithDescription("Total number of storage operations"), metric.WithUnit("{operation}")) if err != nil { logger.Sugar().Fatalf("Failed to create storage operation counter: %v", err) } - - StorageOperationErrors, err = meter.Int64Counter( - "storage.operation.errors", - metric.WithDescription("Number of storage operation errors"), - metric.WithUnit("{error}"), - ) + m.StorageOperationErrors, err = meter.Int64Counter("storage.operation.errors", + metric.WithDescription("Number of storage operation errors"), metric.WithUnit("{error}")) if err != nil { logger.Sugar().Fatalf("Failed to create storage operation errors: %v", err) } } -func initDatabaseMetrics(meter metric.Meter, logger utils.Logger) { +func initDatabaseMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - DBQueryDuration, err = meter.Float64Histogram( - "db.query.duration", - metric.WithDescription("Duration of database queries"), - metric.WithUnit("s"), - ) + m.DBQueryDuration, err = meter.Float64Histogram("db.query.duration", + metric.WithDescription("Duration of database queries"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create DB query duration: %v", err) } - - DBQueryTotal, err = meter.Int64Counter( - "db.query.total", - metric.WithDescription("Total number of database queries"), - metric.WithUnit("{query}"), - ) + m.DBQueryTotal, err = meter.Int64Counter("db.query.total", + metric.WithDescription("Total number of database queries"), metric.WithUnit("{query}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB query counter: %v", err) } - - DBQueryErrors, err = meter.Int64Counter( - "db.query.errors", - metric.WithDescription("Number of database query errors"), - metric.WithUnit("{error}"), - ) + m.DBQueryErrors, err = meter.Int64Counter("db.query.errors", + metric.WithDescription("Number of database query errors"), metric.WithUnit("{error}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB query errors: %v", err) } - - DBConnectionsActive, err = meter.Int64UpDownCounter( - "db.connections.active", - metric.WithDescription("Number of active database connections"), - metric.WithUnit("{connection}"), - ) + m.DBConnectionsActive, err = meter.Int64UpDownCounter("db.connections.active", + metric.WithDescription("Number of active database connections"), metric.WithUnit("{connection}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB active connections: %v", err) } - - DBConnectionsIdle, err = meter.Int64UpDownCounter( - "db.connections.idle", - metric.WithDescription("Number of idle database connections"), - metric.WithUnit("{connection}"), - ) + m.DBConnectionsIdle, err = meter.Int64UpDownCounter("db.connections.idle", + metric.WithDescription("Number of idle database connections"), metric.WithUnit("{connection}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB idle connections: %v", err) } - - DBTransactionTotal, err = meter.Int64Counter( - "db.transaction.total", - metric.WithDescription("Total number of database transactions"), - metric.WithUnit("{transaction}"), - ) + m.DBTransactionTotal, err = meter.Int64Counter("db.transaction.total", + metric.WithDescription("Total number of database transactions"), metric.WithUnit("{transaction}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB transaction counter: %v", err) } - - DBTransactionErrors, err = meter.Int64Counter( - "db.transaction.errors", - metric.WithDescription("Number of database transaction errors"), - metric.WithUnit("{error}"), - ) + m.DBTransactionErrors, err = meter.Int64Counter("db.transaction.errors", + metric.WithDescription("Number of database transaction errors"), metric.WithUnit("{error}")) if err != nil { logger.Sugar().Fatalf("Failed to create DB transaction errors: %v", err) } } -func initQueueMetrics(meter metric.Meter, logger utils.Logger) { +func initQueueMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - QueueMessagePublished, err = meter.Int64Counter( - "queue.message.published", - metric.WithDescription("Number of messages published to queue"), - metric.WithUnit("{message}"), - ) + m.QueueMessagePublished, err = meter.Int64Counter("queue.message.published", + metric.WithDescription("Number of messages published to queue"), metric.WithUnit("{message}")) if err != nil { logger.Sugar().Fatalf("Failed to create queue published counter: %v", err) } - - QueueMessageConsumed, err = meter.Int64Counter( - "queue.message.consumed", - metric.WithDescription("Number of messages consumed from queue"), - metric.WithUnit("{message}"), - ) + m.QueueMessageConsumed, err = meter.Int64Counter("queue.message.consumed", + metric.WithDescription("Number of messages consumed from queue"), metric.WithUnit("{message}")) if err != nil { logger.Sugar().Fatalf("Failed to create queue consumed counter: %v", err) } - - QueueMessageFailed, err = meter.Int64Counter( - "queue.message.failed", - metric.WithDescription("Number of failed queue messages"), - metric.WithUnit("{message}"), - ) + m.QueueMessageFailed, err = meter.Int64Counter("queue.message.failed", + metric.WithDescription("Number of failed queue messages"), metric.WithUnit("{message}")) if err != nil { logger.Sugar().Fatalf("Failed to create queue failed counter: %v", err) } - - QueueProcessingLag, err = meter.Float64Histogram( - "queue.processing.lag", - metric.WithDescription("Queue message processing lag in seconds"), - metric.WithUnit("s"), - ) + // Callback registered later via RegisterQueueDepthFunc once the Redis client is available. + m.QueueDepth, err = meter.Int64ObservableGauge("queue.depth", + metric.WithDescription("Current number of messages in the queue"), metric.WithUnit("{message}")) + if err != nil { + logger.Sugar().Fatalf("Failed to create queue depth gauge: %v", err) + } + m.QueueProcessingLag, err = meter.Float64Histogram("queue.processing.lag", + metric.WithDescription("Queue message processing lag in seconds"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create queue processing lag: %v", err) } } -func initSystemMetrics(meter metric.Meter, logger utils.Logger) { +func initSystemMetrics(m *Metrics, meter metric.Meter, logger *zap.Logger) { var err error - - // Runtime metrics var memStats runtime.MemStats - SystemMemoryUsage, err = meter.Int64ObservableGauge( - "system.memory.usage", + m.SystemMemoryUsage, err = meter.Int64ObservableGauge("system.memory.usage", metric.WithDescription("System memory usage in bytes"), metric.WithUnit("By"), metric.WithInt64Callback(func(_ context.Context, observer metric.Int64Observer) error { @@ -418,9 +323,7 @@ func initSystemMetrics(meter metric.Meter, logger utils.Logger) { if err != nil { logger.Sugar().Fatalf("Failed to create memory usage gauge: %v", err) } - - SystemGoroutineCount, err = meter.Int64ObservableGauge( - "system.goroutine.count", + m.SystemGoroutineCount, err = meter.Int64ObservableGauge("system.goroutine.count", metric.WithDescription("Number of goroutines"), metric.WithUnit("{goroutine}"), metric.WithInt64Callback(func(_ context.Context, observer metric.Int64Observer) error { @@ -431,12 +334,8 @@ func initSystemMetrics(meter metric.Meter, logger utils.Logger) { if err != nil { logger.Sugar().Fatalf("Failed to create goroutine count gauge: %v", err) } - - SystemGCPauseDuration, err = meter.Float64Histogram( - "system.gc.pause.duration", - metric.WithDescription("GC pause duration in seconds"), - metric.WithUnit("s"), - ) + m.SystemGCPauseDuration, err = meter.Float64Histogram("system.gc.pause.duration", + metric.WithDescription("GC pause duration in seconds"), metric.WithUnit("s")) if err != nil { logger.Sugar().Fatalf("Failed to create GC pause duration: %v", err) } diff --git a/internal/metrics/otel.go b/internal/metrics/otel.go index f4eef0d..5c492ae 100644 --- a/internal/metrics/otel.go +++ b/internal/metrics/otel.go @@ -6,7 +6,8 @@ import ( "os" "time" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + "github.com/rndmcodeguy20/mpiper/internal/config" + "go.uber.org/zap" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" @@ -14,83 +15,55 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.17.0" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) // InitTracer initializes OpenTelemetry tracing for distributed tracing. -// It sets up a connection to the OTLP collector and configures the trace provider -// with appropriate resource attributes and sampling strategies. -// -// Returns a shutdown function that should be called on application termination -// to ensure all pending traces are exported. -// -// Example usage: -// -// shutdown := metrics.InitTracer(ctx, logger) -// defer shutdown(ctx) -func InitTracer(ctx context.Context, logger utils.Logger) func(context.Context) error { - // Get OTLP collector endpoint from environment with fallback - endpoint := getEnvOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317") - - // Strip any URL scheme (grpc://, http://, https://) as the OTLP exporter only accepts host:port - endpoint = stripURLScheme(endpoint) +// Returns a shutdown function that should be called on application termination. +func InitTracer(ctx context.Context, logger *zap.Logger) func(context.Context) error { + otelCfg := config.MustGet().Otel + endpoint := stripURLScheme(otelCfg.Endpoint) logger.Sugar().Infof("Initializing OpenTelemetry tracer with endpoint: %s", endpoint) - // Configure gRPC connection options for production reliability opts := []otlptracegrpc.Option{ otlptracegrpc.WithEndpoint(endpoint), - // TODO: Enable TLS in production environments - // Replace WithInsecure() with proper TLS configuration: - // otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(certPool, "")), - otlptracegrpc.WithInsecure(), - - // Retry configuration for handling transient failures otlptracegrpc.WithRetry(otlptracegrpc.RetryConfig{ Enabled: true, InitialInterval: 5 * time.Second, MaxInterval: 30 * time.Second, MaxElapsedTime: 5 * time.Minute, }), - - // Connection timeout to prevent hanging on startup otlptracegrpc.WithTimeout(10 * time.Second), - - // Compression to reduce network bandwidth otlptracegrpc.WithCompressor("gzip"), - - // Additional gRPC dial options - otlptracegrpc.WithDialOption( - grpc.WithDefaultCallOptions( - grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB max message size - ), - ), otlptracegrpc.WithDialOption( - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100 * 1024 * 1024)), ), } + if otelCfg.TLSInsecure { + opts = append(opts, + otlptracegrpc.WithInsecure(), + otlptracegrpc.WithDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + ) + } else { + opts = append(opts, + otlptracegrpc.WithDialOption(grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))), + ) + } - // Create OTLP trace exporter with production-ready configuration exp, err := otlptracegrpc.New(ctx, opts...) if err != nil { logger.Sugar().Fatalf("Failed to create OTLP trace exporter: %v", err) } - // Build resource attributes with environment information - // This helps identify traces by service, version, environment, etc. res, err := resource.New(ctx, resource.WithAttributes( - // Service identification - semconv.ServiceName(getEnvOrDefault("SERVICE_NAME", "mpiper-api")), - semconv.ServiceVersion(getEnvOrDefault("SERVICE_VERSION", "dev")), - - // Deployment environment - semconv.DeploymentEnvironment(getEnvOrDefault("DEPLOYMENT_ENV", "development")), - - // Instance identification + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(otelCfg.ServiceVersion), + semconv.DeploymentEnvironment(otelCfg.DeploymentEnv), semconv.ServiceInstanceID(getInstanceID()), ), - // Automatically detect additional resource attributes resource.WithFromEnv(), resource.WithProcess(), resource.WithOS(), @@ -99,104 +72,58 @@ func InitTracer(ctx context.Context, logger utils.Logger) func(context.Context) ) if err != nil { logger.Sugar().Warnf("Failed to create resource attributes: %v", err) - // Use default resource if creation fails res = resource.Default() } - // Configure trace provider with appropriate sampling strategy tp := sdktrace.NewTracerProvider( - // Use batch span processor for better performance - // Batches spans before sending to reduce network overhead sdktrace.WithBatcher(exp, - sdktrace.WithMaxQueueSize(2048), // Queue size for batching - sdktrace.WithBatchTimeout(5*time.Second), // Max time before forcing batch send - sdktrace.WithMaxExportBatchSize(512), // Max spans per batch + sdktrace.WithMaxQueueSize(2048), + sdktrace.WithBatchTimeout(5*time.Second), + sdktrace.WithMaxExportBatchSize(512), ), - - // Attach resource attributes to all spans sdktrace.WithResource(res), - - // Sampling strategy: always sample in dev, configurable in prod - // Production: Consider using ParentBased or TraceIDRatioBased sampler - sdktrace.WithSampler(getSampler()), - - // Span limits to prevent excessive resource usage + sdktrace.WithSampler(getSampler(otelCfg)), sdktrace.WithSpanLimits(sdktrace.SpanLimits{ - AttributeValueLengthLimit: 4096, // Max attribute value length - AttributeCountLimit: 128, // Max attributes per span - EventCountLimit: 128, // Max events per span - LinkCountLimit: 128, // Max links per span - AttributePerEventCountLimit: 128, // Max attributes per event - AttributePerLinkCountLimit: 128, // Max attributes per link + AttributeValueLengthLimit: 4096, + AttributeCountLimit: 128, + EventCountLimit: 128, + LinkCountLimit: 128, + AttributePerEventCountLimit: 128, + AttributePerLinkCountLimit: 128, }), ) - // Set global tracer provider otel.SetTracerProvider(tp) - - // Configure propagators for context propagation across service boundaries - // W3C Trace Context is the standard, Baggage allows passing custom metadata otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( - propagation.TraceContext{}, // W3C Trace Context standard - propagation.Baggage{}, // W3C Baggage for custom metadata + propagation.TraceContext{}, + propagation.Baggage{}, )) logger.Sugar().Info("OpenTelemetry tracer initialized successfully") - - // Return shutdown function to be called on application termination return tp.Shutdown } -// getSampler returns the appropriate sampler based on environment configuration -// Development: Always sample for debugging -// Production: Sample based on configuration or use parent-based sampling -func getSampler() sdktrace.Sampler { - env := os.Getenv("DEPLOYMENT_ENV") - - // Always sample in development for easier debugging +func getSampler(otelCfg config.OtelConfig) sdktrace.Sampler { + env := otelCfg.DeploymentEnv if env == "development" || env == "dev" || env == "" { return sdktrace.AlwaysSample() } - - // In production, use parent-based sampling with ratio - // This samples based on incoming trace decision and ratio for new traces - samplingRate := 0.1 // 10% sampling rate, adjust based on traffic volume - if rate := os.Getenv("TRACE_SAMPLING_RATE"); rate != "" { - if parsed, err := fmt.Sscanf(rate, "%f", &samplingRate); err == nil && parsed == 1 { - // Successfully parsed custom sampling rate - } - } - return sdktrace.ParentBased( - sdktrace.TraceIDRatioBased(samplingRate), + sdktrace.TraceIDRatioBased(otelCfg.TraceSamplingRate), ) } -// getEnvOrDefault retrieves an environment variable or returns a default value -func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -// getInstanceID generates a unique instance identifier -// In Kubernetes, this should be the pod name or hostname +// getInstanceID returns the hostname (pod name in Kubernetes) or a fallback. func getInstanceID() string { - // Try to get hostname (pod name in Kubernetes) if hostname, err := os.Hostname(); err == nil { return hostname } - // Fallback to a generated ID return fmt.Sprintf("instance-%d", time.Now().Unix()) } -// stripURLScheme removes URL schemes (grpc://, http://, https://) from the endpoint -// The OTLP gRPC exporter expects only host:port format +// stripURLScheme removes grpc://, http://, https:// from the endpoint. func stripURLScheme(endpoint string) string { - // Remove common URL schemes - schemes := []string{"grpc://", "http://", "https://"} - for _, scheme := range schemes { + for _, scheme := range []string{"grpc://", "http://", "https://"} { if len(endpoint) > len(scheme) && endpoint[:len(scheme)] == scheme { return endpoint[len(scheme):] } diff --git a/internal/middleware/authorization.go b/internal/middleware/authorization.go index 2fd20fc..4b707f7 100644 --- a/internal/middleware/authorization.go +++ b/internal/middleware/authorization.go @@ -5,48 +5,48 @@ import ( "net/http" "strings" + "github.com/rndmcodeguy20/mpiper/internal/config" "github.com/rndmcodeguy20/mpiper/pkg/errors" "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.uber.org/zap" ) +type contextKey string + const userIDKey contextKey = "user_id" // AuthMiddleware validates the token, extracts the user ID, and injects it into the context. -func AuthMiddleware(logger *utils.Logger) func(http.Handler) http.Handler { +func AuthMiddleware(l *zap.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { - logger.Warn("Authorization header is empty") + l.Warn("Authorization header is empty") utils.WriteErrorResponse(w, errors.NewUnauthorizedError("Missing Authorization header", nil)) return } - // Expect "Bearer " parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - logger.Warn("Invalid Authorization header format") + l.Warn("Invalid Authorization header format") utils.WriteErrorResponse(w, errors.NewUnauthorizedError("Invalid Authorization format", nil)) return } token := parts[1] if token == "" { - logger.Warn("Token is empty") + l.Warn("Token is empty") utils.WriteErrorResponse(w, errors.NewUnauthorizedError("Empty token", nil)) return } - // Decrypt token to get user ID - userID, err := utils.DecryptToken(token) + userID, err := utils.DecryptToken(token, config.MustGet().EncryptionKey) if err != nil { - logger.Warn("Invalid or expired token", zap.Error(err)) + l.Warn("Invalid or expired token", zap.Error(err)) utils.WriteErrorResponse(w, errors.NewUnauthorizedError("Invalid token", err)) return } - // Attach userID to context ctx := context.WithValue(r.Context(), userIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go index 73e3766..a8cd7bc 100644 --- a/internal/middleware/logging.go +++ b/internal/middleware/logging.go @@ -5,78 +5,58 @@ import ( "net/http" "time" - "github.com/go-chi/chi/v5/middleware" + chiMiddleware "github.com/go-chi/chi/v5/middleware" + applogger "github.com/rndmcodeguy20/mpiper/pkg/logger" "go.uber.org/zap" - - "github.com/rndmcodeguy20/mpiper/pkg/utils" ) -// contextKey is a custom type for context keys -type contextKey string - -const loggerKey contextKey = "logger" - -// LoggerFromContext retrieves the logger from context -func LoggerFromContext(ctx context.Context) *utils.Logger { - if logger, ok := ctx.Value(loggerKey).(*utils.Logger); ok { - return logger - } - // Return default logger if not found - return utils.NewLogger() +// LoggerFromContext retrieves the request-scoped logger from ctx. +func LoggerFromContext(ctx context.Context) *zap.Logger { + return applogger.FromContext(ctx) } -// LoggerMiddleware integrates with Chi's RequestID and other middleware -func LoggerMiddleware(logger *utils.Logger) func(next http.Handler) http.Handler { +// LoggerMiddleware injects a request-scoped logger into the context and logs +// request/response details. +func LoggerMiddleware(l *zap.Logger) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - // Get request ID from Chi middleware (if available) - requestID := middleware.GetReqID(r.Context()) + requestID := chiMiddleware.GetReqID(r.Context()) if requestID == "" { requestID = generateRequestID() } - // Create context logger with all Chi-provided fields - ctxLogger := logger.WithFields(map[string]interface{}{ - "request_id": requestID, - "method": r.Method, - "path": r.URL.Path, - "remote_addr": r.RemoteAddr, - "user_agent": r.UserAgent(), - "proto": r.Proto, - }) - - // Store logger in context for downstream handlers - ctx := context.WithValue(r.Context(), loggerKey, ctxLogger) - r = r.WithContext(ctx) + reqLogger := l.With( + zap.String("request_id", requestID), + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + zap.String("user_agent", r.UserAgent()), + zap.String("proto", r.Proto), + ) - // Wrap response writer to capture status and size - ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + ctx := applogger.WithLogger(r.Context(), reqLogger) + r = r.WithContext(ctx) - // Add request ID to response headers + ww := chiMiddleware.NewWrapResponseWriter(w, r.ProtoMajor) ww.Header().Set("X-Request-ID", requestID) - // Log incoming request - ctxLogger.Info("Incoming request") + reqLogger.Info("Incoming request") - // Call next handler next.ServeHTTP(ww, r) - // Calculate duration + status := ww.Status() duration := time.Since(start) - // Determine log level based on status code - status := ww.Status() - logFunc := ctxLogger.Info + logFn := reqLogger.Info if status >= 500 { - logFunc = ctxLogger.Error + logFn = reqLogger.Error } else if status >= 400 { - logFunc = ctxLogger.Warn + logFn = reqLogger.Warn } - // Log completed request - logFunc("Request completed", + logFn("Request completed", zap.Int("status", status), zap.String("duration", durationInUnits(duration)), zap.Int("bytes_written", ww.BytesWritten()), diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go index fca0295..a58f9a4 100644 --- a/internal/middleware/recovery.go +++ b/internal/middleware/recovery.go @@ -3,31 +3,26 @@ package middleware import ( "net/http" - "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.uber.org/zap" ) -// RecoveryMiddleware catches panics and logs them -func RecoveryMiddleware(logger *utils.Logger) func(next http.Handler) http.Handler { +// RecoveryMiddleware catches panics and logs them. +func RecoveryMiddleware(l *zap.Logger) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { - // Get context logger if available ctxLogger := LoggerFromContext(r.Context()) - ctxLogger.Error("Panic recovered", zap.Any("panic", err), zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Stack("stack"), ) - w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error": "Internal Server Error"}`)) } }() - next.ServeHTTP(w, r) }) } diff --git a/internal/middleware/slow_request.go b/internal/middleware/slow_request.go index d736882..dc6d26c 100644 --- a/internal/middleware/slow_request.go +++ b/internal/middleware/slow_request.go @@ -4,20 +4,17 @@ import ( "net/http" "time" - "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.uber.org/zap" ) -// SlowRequestMiddleware logs slow requests -func SlowRequestMiddleware(logger *utils.Logger, threshold time.Duration) func(next http.Handler) http.Handler { +// SlowRequestMiddleware logs requests that exceed threshold. +func SlowRequestMiddleware(l *zap.Logger, threshold time.Duration) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - next.ServeHTTP(w, r) - duration := time.Since(start) - if duration > threshold { + if duration := time.Since(start); duration > threshold { ctxLogger := LoggerFromContext(r.Context()) ctxLogger.Warn("Slow request detected", zap.Duration("duration", duration), diff --git a/internal/queue/queue.go b/internal/queue/queue.go index ab52cab..3197f11 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -10,7 +10,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/rndmcodeguy20/mpiper/internal/metrics" appErrors "github.com/rndmcodeguy20/mpiper/pkg/errors" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + "go.uber.org/zap" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -18,6 +18,7 @@ import ( "go.opentelemetry.io/otel/trace" ) + type RedisQueueOptions struct { QueueName string MaxRetries int @@ -40,11 +41,11 @@ type RedisQueue struct { ctx context.Context redis *RedisClient options RedisQueueOptions - logger *utils.Logger + logger *zap.Logger + m *metrics.Metrics } -func NewRedisQueue(ctx context.Context, client *RedisClient, options RedisQueueOptions, logger *utils.Logger) *RedisQueue { - // validate options +func NewRedisQueue(ctx context.Context, client *RedisClient, options RedisQueueOptions, logger *zap.Logger, m *metrics.Metrics) *RedisQueue { if options.QueueName == "" { options.QueueName = "media:jobs" } @@ -67,15 +68,19 @@ func NewRedisQueue(ctx context.Context, client *RedisClient, options RedisQueueO options.PoolSize = 10 } if options.MaxStreamLength <= 0 { - options.MaxStreamLength = 10_000 // default max stream length + options.MaxStreamLength = 10_000 } - return &RedisQueue{ - ctx: ctx, - redis: client, - options: options, - logger: logger, + rq := &RedisQueue{ctx: ctx, redis: client, options: options, logger: logger, m: m} + + if m != nil { + streamName := options.QueueName + _ = m.RegisterQueueDepthFunc(func(ctx context.Context) (int64, error) { + return client.XLen(ctx, streamName) + }) } + + return rq } func (rq *RedisQueue) Enqueue(ctx context.Context, payload map[string]interface{}) (string, error) { @@ -138,30 +143,25 @@ func (rq *RedisQueue) Enqueue(ctx context.Context, payload map[string]interface{ span.RecordError(err) span.SetStatus(codes.Error, "Failed to enqueue message") - // Record failure metrics - if metrics.QueueMessageFailed != nil { + if rq.m != nil { attrs := []attribute.KeyValue{ attribute.String("queue.name", rq.options.QueueName), attribute.String("error.type", "publish_failed"), } - metrics.QueueMessageFailed.Add(ctx, 1, metric.WithAttributes(attrs...)) + rq.m.QueueMessageFailed.Add(ctx, 1, metric.WithAttributes(attrs...)) } return "", err } - // Record success metrics duration := time.Since(start).Seconds() attrs := []attribute.KeyValue{ attribute.String("queue.name", rq.options.QueueName), } - if metrics.QueueMessagePublished != nil { - metrics.QueueMessagePublished.Add(ctx, 1, metric.WithAttributes(attrs...)) - } - - if metrics.QueueProcessingLag != nil { - metrics.QueueProcessingLag.Record(ctx, duration, metric.WithAttributes(attrs...)) + if rq.m != nil { + rq.m.QueueMessagePublished.Add(ctx, 1, metric.WithAttributes(attrs...)) + rq.m.QueueProcessingLag.Record(ctx, duration, metric.WithAttributes(attrs...)) } span.SetAttributes(attribute.String("message.id", id)) diff --git a/internal/queue/redis.go b/internal/queue/redis.go index 4854c12..e438ced 100644 --- a/internal/queue/redis.go +++ b/internal/queue/redis.go @@ -1,10 +1,12 @@ package queue import ( + "context" + "github.com/redis/go-redis/v9" "github.com/rndmcodeguy20/mpiper/internal/config" "github.com/rndmcodeguy20/mpiper/pkg/errors" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + "go.uber.org/zap" ) type RedisClient struct { @@ -12,7 +14,7 @@ type RedisClient struct { client *redis.Client } -func MustGetRedisClient(cfg *config.RedisConfig, logger *utils.Logger) (*RedisClient, error) { +func MustGetRedisClient(cfg *config.RedisConfig, logger *zap.Logger) (*RedisClient, error) { var redisAddr string var redisPassword string var redisDB int @@ -47,3 +49,7 @@ func MustGetRedisClient(cfg *config.RedisConfig, logger *utils.Logger) (*RedisCl client: rdb, }, nil } + +func (rc *RedisClient) XLen(ctx context.Context, stream string) (int64, error) { + return rc.client.XLen(ctx, stream).Result() +} diff --git a/internal/repository/asset_repo.go b/internal/repository/asset_repo.go index ffa22cf..c81fd08 100644 --- a/internal/repository/asset_repo.go +++ b/internal/repository/asset_repo.go @@ -14,9 +14,9 @@ import ( "github.com/jmoiron/sqlx" "github.com/rndmcodeguy20/mpiper/internal/metrics" appErrors "github.com/rndmcodeguy20/mpiper/pkg/errors" - "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" ) type AssetType string @@ -55,7 +55,7 @@ func ToAssetType(fileType string) AssetType { } func ToAssetTypeFromMimeType(mimeType string) AssetType { - if len(mimeType) == 0 { + if len(mimeType) < 5 { return OtherAsset } switch { @@ -76,7 +76,6 @@ func ToAssetTypeFromMimeType(mimeType string) AssetType { type AssetRepository interface { CreateAsset(ictx context.Context, d uuid.UUID, url string, size int64, fileType AssetType, mimeType string) error CreateAssetTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, url string, size int64, fileType AssetType, mimeType string) error - MarkAssetUploaded(id uuid.UUID) error MarkAssetUploadedTx(ctx context.Context, tx *sql.Tx, id uuid.UUID) (bool, error) InsertProcessAssetJobTx(ctx context.Context, tx *sql.Tx, assetID uuid.UUID) (*int64, error) GetDB() *sqlx.DB @@ -84,14 +83,12 @@ type AssetRepository interface { type assetRepo struct { db *sqlx.DB - logger *utils.Logger + logger *zap.Logger + m *metrics.Metrics } -func NewAssetRepository(db *sqlx.DB, logger *utils.Logger) AssetRepository { - return &assetRepo{ - db: db, - logger: logger, - } +func NewAssetRepository(db *sqlx.DB, logger *zap.Logger, m *metrics.Metrics) AssetRepository { + return &assetRepo{db: db, logger: logger, m: m} } func (r *assetRepo) GetDB() *sqlx.DB { @@ -121,19 +118,17 @@ func (r *assetRepo) CreateAsset(ctx context.Context, id uuid.UUID, url string, s if err != nil { attrs = append(attrs, attribute.String("db.status", "error")) - if metrics.DBQueryErrors != nil { - metrics.DBQueryErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) + if r.m != nil { + r.m.DBQueryErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) } r.logger.Sugar().Errorf("Failed to create asset: %v", err) return appErrors.NewInternalServerError("Could not insert row", err) } attrs = append(attrs, attribute.String("db.status", "success")) - if metrics.DBQueryTotal != nil { - metrics.DBQueryTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) - } - if metrics.DBQueryDuration != nil { - metrics.DBQueryDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) + if r.m != nil { + r.m.DBQueryTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) + r.m.DBQueryDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) } return nil @@ -162,38 +157,22 @@ func (r *assetRepo) CreateAssetTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, if err != nil { attrs = append(attrs, attribute.String("db.status", "error")) - if metrics.DBQueryErrors != nil { - metrics.DBQueryErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) + if r.m != nil { + r.m.DBQueryErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) } r.logger.Sugar().Errorf("Failed to create asset in transaction: %v", err) return appErrors.NewInternalServerError("Could not insert row in transaction", err) } attrs = append(attrs, attribute.String("db.status", "success")) - if metrics.DBQueryTotal != nil { - metrics.DBQueryTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) - } - if metrics.DBQueryDuration != nil { - metrics.DBQueryDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) + if r.m != nil { + r.m.DBQueryTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) + r.m.DBQueryDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) } return nil } -func (r *assetRepo) MarkAssetUploaded(id uuid.UUID) error { - query := `UPDATE assets SET status = $1 WHERE asset_id = $2;` - _, err := r.db.Exec( - query, - StatusUploaded, - id, - ) - if err != nil { - r.logger.Sugar().Errorf("Failed to mark asset as uploaded: %v", err) - return appErrors.NewInternalServerError("Could not update row", err) - } - return nil -} - func (r *assetRepo) MarkAssetUploadedTx(ctx context.Context, tx *sql.Tx, id uuid.UUID) (bool, error) { query := `UPDATE assets SET status = $1, updated_at = NOW() WHERE asset_id = $2 AND status = 'uploading';` res, err := tx.ExecContext( @@ -223,7 +202,7 @@ func (r *assetRepo) InsertProcessAssetJobTx(ctx context.Context, tx *sql.Tx, ass var jobID int64 query := `INSERT INTO jobs (type, asset_id, status) VALUES ($1, $2, $3) - ON CONFLICT (asset_id, type) DO NOTHING + ON CONFLICT (asset_id, type) DO UPDATE SET updated_at = NOW() RETURNING job_id;` err := tx.QueryRowContext( diff --git a/internal/router/router.go b/internal/router/router.go index 1fd6df0..578fec0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -3,6 +3,8 @@ package router import ( "math/rand" "net/http" + "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -12,10 +14,13 @@ import ( "github.com/rndmcodeguy20/mpiper/internal/config" "github.com/rndmcodeguy20/mpiper/internal/handler" appMiddleware "github.com/rndmcodeguy20/mpiper/internal/middleware" + "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/internal/repository" "github.com/rndmcodeguy20/mpiper/internal/service" + applogger "github.com/rndmcodeguy20/mpiper/pkg/logger" "github.com/rndmcodeguy20/mpiper/pkg/utils" "github.com/rndmcodeguy20/mpiper/pkg/utils/storagex" + "golang.org/x/time/rate" ) const ( @@ -23,15 +28,73 @@ const ( MiddlewareTimeout = 30 * time.Second ) -func NewRouter(cfg config.EnvConfig, db *sqlx.DB) *chi.Mux { +// presignRateLimiter returns a per-IP rate-limit middleware. +// Each IP is allowed 10 requests/s with a burst of 20. +func presignRateLimiter() func(http.Handler) http.Handler { + type entry struct { + lim *rate.Limiter + lastSeen time.Time + } + var ( + mu sync.Mutex + clients = make(map[string]*entry) + ) + // Evict IPs not seen in the last 5 minutes to prevent unbounded growth. + go func() { + for range time.Tick(time.Minute) { + mu.Lock() + for ip, e := range clients { + if time.Since(e.lastSeen) > 5*time.Minute { + delete(clients, ip) + } + } + mu.Unlock() + } + }() + + getLimiter := func(ip string) *rate.Limiter { + mu.Lock() + defer mu.Unlock() + e, ok := clients[ip] + if !ok { + e = &entry{lim: rate.NewLimiter(rate.Limit(10), 20)} + clients[ip] = e + } + e.lastSeen = time.Now() + return e.lim + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ip = strings.SplitN(xff, ",", 2)[0] + } + if !getLimiter(strings.TrimSpace(ip)).Allow() { + http.Error(w, `{"status":"error","message":"rate limit exceeded"}`, http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func NewRouter(cfg config.EnvConfig, db *sqlx.DB, m *metrics.Metrics) *chi.Mux { r := chi.NewRouter() - logger := utils.NewLogger() + cfg2 := config.MustGet() + logger := applogger.New(applogger.Config{ + ServiceName: cfg2.Otel.ServiceName, + Environment: cfg2.Environment, + Level: applogger.ParseLevel(cfg2.LogLevel), + }) + + allowedOrigins := config.MustGet().CORSAllowedOrigins // Middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"http://localhost:5173"}, + AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, @@ -41,32 +104,25 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB) *chi.Mux { r.Use(appMiddleware.LoggerMiddleware(logger)) r.Use(middleware.Timeout(MiddlewareTimeout)) r.Use(appMiddleware.TracingMiddleware) - r.Use(appMiddleware.MetricsMiddleware) + r.Use(appMiddleware.MetricsMiddleware(m)) r.Use(appMiddleware.RecoveryMiddleware(logger)) - r.Use(middleware.Compress(5)) r.Use(appMiddleware.SlowRequestMiddleware(logger, 2*time.Second)) - assetRepo := repository.NewAssetRepository(db, logger) - assetSvc := service.NewAssetService(&cfg.Redis, storagex.GCPProvider, assetRepo, logger) - assetHandler := handler.NewAssetHandler(assetSvc, logger) + assetRepo := repository.NewAssetRepository(db, logger, m) + assetSvc := service.NewAssetService(&cfg.Redis, storagex.GCPProvider, assetRepo, logger, m) + assetHandler := handler.NewAssetHandler(assetSvc, logger, m) - // API Routes + // Routes r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"status": "ok", "message": "Welcome to mpiper API"}`)) - if err != nil { - return - } + _, _ = w.Write([]byte(`{"status": "ok", "message": "Welcome to mpiper API"}`)) }) r.Get("/metric_test", func(w http.ResponseWriter, r *http.Request) { - time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond) // Simulate variable processing time + time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond) w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"status": "ok", "message": "Metric test endpoint"}`)) - if err != nil { - return - } + _, _ = w.Write([]byte(`{"status": "ok", "message": "Metric test endpoint"}`)) }) r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { @@ -75,33 +131,20 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB) *chi.Mux { r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"status": "ok", "message": "mpiper is healthy"}`)) - if err != nil { - return - } + _, _ = w.Write([]byte(`{"status": "ok", "message": "mpiper is healthy"}`)) }) - // v1 API Routes - r.Route("/api/"+APIVersion, func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"status": "ok", "message": "mpiper API v1"}`)) - if err != nil { - return - } + _, _ = w.Write([]byte(`{"status": "ok", "message": "mpiper API v1"}`)) }) r.Get("/status", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - utils.RespondJSON( - w, - map[string]string{"status": "ok", "message": "mpiper API is running"}, - http.StatusOK, - ) + utils.RespondJSON(w, map[string]string{"status": "ok", "message": "mpiper API is running"}, http.StatusOK) }) r.Route("/storage", func(r chi.Router) { - r.Post("/presign", assetHandler.CreateAsset) + r.With(presignRateLimiter()).Post("/presign", assetHandler.CreateAsset) }) r.Route("/assets", func(r chi.Router) { diff --git a/internal/server/server.go b/internal/server/server.go index 2f3b811..46aa1df 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,28 +7,34 @@ import ( "github.com/jmoiron/sqlx" "github.com/rndmcodeguy20/mpiper/internal/config" + "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/internal/router" - "github.com/rndmcodeguy20/mpiper/pkg/utils" + applogger "github.com/rndmcodeguy20/mpiper/pkg/logger" + "go.uber.org/zap" ) type AppServer struct { db *sqlx.DB - logger *utils.Logger + logger *zap.Logger httpServer *http.Server } -func NewServer(db *sqlx.DB, cfg config.EnvConfig) *AppServer { - r := router.NewRouter(cfg, db) - logger := utils.NewLogger() +func NewServer(db *sqlx.DB, cfg config.EnvConfig, m *metrics.Metrics) *AppServer { + cfg2 := config.MustGet() + l := applogger.New(applogger.Config{ + ServiceName: cfg2.Otel.ServiceName, + Environment: cfg2.Environment, + Level: applogger.ParseLevel(cfg2.LogLevel), + }) srv := &http.Server{ Addr: cfg.Server.Host + ":" + strconv.Itoa(cfg.Server.Port), - Handler: r, + Handler: router.NewRouter(cfg, db, m), } return &AppServer{ db: db, - logger: logger, + logger: l, httpServer: srv, } } diff --git a/internal/service/asset.go b/internal/service/asset.go index a077005..b5a5d8a 100644 --- a/internal/service/asset.go +++ b/internal/service/asset.go @@ -13,7 +13,6 @@ import ( "github.com/rndmcodeguy20/mpiper/internal/queue" lredis "github.com/rndmcodeguy20/mpiper/internal/queue" "github.com/rndmcodeguy20/mpiper/internal/repository" - "github.com/rndmcodeguy20/mpiper/pkg/utils" "github.com/rndmcodeguy20/mpiper/pkg/utils/storagex" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -29,22 +28,23 @@ type AssetService interface { type assetService struct { assetRepo repository.AssetRepository - logger *utils.Logger + logger *zap.Logger storageClient storagex.StorageX queue *queue.RedisQueue + m *metrics.Metrics } -func NewAssetService(redisCfg *config.RedisConfig, provider storagex.Provider, assetRepo repository.AssetRepository, logger *utils.Logger) AssetService { +func NewAssetService(redisCfg *config.RedisConfig, provider storagex.Provider, assetRepo repository.AssetRepository, logger *zap.Logger, m *metrics.Metrics) AssetService { var storageClient storagex.StorageX var err error ctx := context.Background() switch provider { - //case storagex.AWSProvider: - // storageClient = storagex.NewAWSStorageX() case storagex.GCPProvider: - storageClient, err = storagex.NewGCSStorageFromServiceAccountJSON(ctx, ".secrets/aion-staging-d4d9b9c808ec.json") - case storagex.AzureProvider: - //storageClient = storagex.NewAzureStorageX() + saPath := config.MustGet().Storage.GCS.SAPath + if saPath == "" { + logger.Sugar().Fatalf("GCS_SA_PATH is not set") + } + storageClient, err = storagex.NewGCSStorageFromServiceAccountJSON(ctx, saPath, m, logger) default: logger.Sugar().Fatalf("Unsupported storage provider: %v", provider) } @@ -61,13 +61,14 @@ func NewAssetService(redisCfg *config.RedisConfig, provider storagex.Provider, a MaxRetries: 3, RetryInterval: 2 * time.Second, EnableMetrics: true, - }, logger) + }, logger, m) return &assetService{ assetRepo: assetRepo, logger: logger, storageClient: storageClient, queue: rq, + m: m, } } @@ -131,34 +132,27 @@ func (s *assetService) CreateAsset(ctx context.Context, request models.UploadAss spanStorage.RecordError(err) // Record failure metric - if metrics.AssetUploadTotal != nil { + if s.m != nil { attrs := []attribute.KeyValue{ attribute.String("status", "error"), attribute.String("error.type", "db_error"), } - metrics.AssetUploadTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) + s.m.AssetUploadTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) } return nil, err } - // Record successful asset creation duration := time.Since(start).Seconds() attrs := []attribute.KeyValue{ attribute.String("status", "success"), attribute.String("asset_type", string(repository.ToAssetTypeFromMimeType(request.ContentType))), } - if metrics.AssetUploadTotal != nil { - metrics.AssetUploadTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) - } - - if metrics.AssetUploadDuration != nil { - metrics.AssetUploadDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) - } - - if metrics.AssetSizeBytes != nil { - metrics.AssetSizeBytes.Record(ctx, request.Size, metric.WithAttributes(attrs...)) + if s.m != nil { + s.m.AssetUploadTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) + s.m.AssetUploadDuration.Record(ctx, duration, metric.WithAttributes(attrs...)) + s.m.AssetSizeBytes.Record(ctx, request.Size, metric.WithAttributes(attrs...)) } span.SetStatus(codes.Ok, "Asset created successfully") diff --git a/pkg/logger/config.go b/pkg/logger/config.go new file mode 100644 index 0000000..73e5c2a --- /dev/null +++ b/pkg/logger/config.go @@ -0,0 +1,23 @@ +package logger + +import "go.uber.org/zap/zapcore" + +type Config struct { + ServiceName string + Environment string // "dev" | "prod" + + Level zapcore.Level + + // EnableOTel wires logs into the global OTel log provider (optional). + EnableOTel bool +} + +// ParseLevel converts a log-level string (case-insensitive) to zapcore.Level. +// Unknown strings default to InfoLevel. +func ParseLevel(s string) zapcore.Level { + var l zapcore.Level + if err := l.UnmarshalText([]byte(s)); err != nil { + return zapcore.InfoLevel + } + return l +} diff --git a/pkg/logger/context.go b/pkg/logger/context.go new file mode 100644 index 0000000..37f5820 --- /dev/null +++ b/pkg/logger/context.go @@ -0,0 +1,20 @@ +package logger + +import ( + "context" + + "go.uber.org/zap" +) + +type contextKey struct{} + +func WithLogger(ctx context.Context, l *zap.Logger) context.Context { + return context.WithValue(ctx, contextKey{}, l) +} + +func FromContext(ctx context.Context) *zap.Logger { + if l, ok := ctx.Value(contextKey{}).(*zap.Logger); ok { + return l + } + return zap.L() +} diff --git a/pkg/logger/helpers.go b/pkg/logger/helpers.go new file mode 100644 index 0000000..8c7ac02 --- /dev/null +++ b/pkg/logger/helpers.go @@ -0,0 +1,7 @@ +package logger + +import "go.uber.org/zap" + +func WithRequestID(l *zap.Logger, requestID string) *zap.Logger { + return l.With(zap.String("request_id", requestID)) +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..2acf3bb --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,78 @@ +package logger + +import ( + "os" + + "go.opentelemetry.io/contrib/bridges/otelzap" + "go.opentelemetry.io/otel/log/global" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func New(cfg Config) *zap.Logger { + if cfg.Environment == "prod" || cfg.Environment == "production" { + return newProdLogger(cfg) + } + return newDevLogger(cfg) +} + +func newProdLogger(cfg Config) *zap.Logger { + encoderCfg := zapcore.EncoderConfig{ + TimeKey: "ts", + LevelKey: "level", + MessageKey: "msg", + CallerKey: "caller", + StacktraceKey: "stacktrace", + + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.AddSync(os.Stdout), + cfg.Level, + ) + core = withOTelCore(cfg, core) + + return zap.New(core, + zap.AddCaller(), + zap.AddStacktrace(zapcore.ErrorLevel), + ).With( + zap.String("service", cfg.ServiceName), + zap.String("env", cfg.Environment), + ) +} + +func newDevLogger(cfg Config) *zap.Logger { + core := &prettyCore{ + LevelEnabler: cfg.Level, + } + otelCore := withOTelCore(cfg, core) + + return zap.New(otelCore, + zap.AddCaller(), + ).With( + zap.String("service", cfg.ServiceName), + zap.String("env", cfg.Environment), + ) +} + +func withOTelCore(cfg Config, base zapcore.Core) zapcore.Core { + if !cfg.EnableOTel { + return base + } + + otelCore := otelzap.NewCore( + cfg.ServiceName, + otelzap.WithLoggerProvider(global.GetLoggerProvider()), + ) + + filteredOTelCore, err := zapcore.NewIncreaseLevelCore(otelCore, cfg.Level) + if err != nil { + return base + } + + return zapcore.NewTee(base, filteredOTelCore) +} diff --git a/pkg/logger/prettycore.go b/pkg/logger/prettycore.go new file mode 100644 index 0000000..8a3a306 --- /dev/null +++ b/pkg/logger/prettycore.go @@ -0,0 +1,98 @@ +package logger + +import ( + "fmt" + "os" + + "go.uber.org/zap/zapcore" +) + +type prettyCore struct { + zapcore.LevelEnabler + fields []zapcore.Field +} + +func (c *prettyCore) With(fields []zapcore.Field) zapcore.Core { + newFields := make([]zapcore.Field, len(c.fields)+len(fields)) + copy(newFields, c.fields) + copy(newFields[len(c.fields):], fields) + + return &prettyCore{ + LevelEnabler: c.LevelEnabler, + fields: newFields, + } +} + +func (c *prettyCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(entry.Level) { + return ce.AddCore(entry, c) + } + return ce +} + +func (c *prettyCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { + allFields := append(c.fields, fields...) + + enc := zapcore.NewMapObjectEncoder() + for _, f := range allFields { + f.AddTo(enc) + } + + ts := entry.Time.Format("15:04:05") + level := colorLevel(entry.Level) + caller := entry.Caller.TrimmedPath() + + line := fmt.Sprintf( + "%s %s %s %s\n", + grey(ts), + level, + caller, + entry.Message, + ) + + if len(enc.Fields) > 0 { + line += "\n" + + maxLen := 0 + for k := range enc.Fields { + if len(k) > maxLen { + maxLen = len(k) + } + } + + for k, v := range enc.Fields { + line += fmt.Sprintf(" %s: %v\n", + grey(fmt.Sprintf("%-*s", maxLen, k)), + v, + ) + } + } + + line += "\n" + + _, err := os.Stdout.Write([]byte(line)) + return err +} + +func (c *prettyCore) Sync() error { return nil } + +func grey(s string) string { return "\033[90m" + s + "\033[0m" } +func green(s string) string { return "\033[32m" + s + "\033[0m" } +func yellow(s string) string { return "\033[33m" + s + "\033[0m" } +func red(s string) string { return "\033[31m" + s + "\033[0m" } +func blue(s string) string { return "\033[34m" + s + "\033[0m" } + +func colorLevel(l zapcore.Level) string { + switch l { + case zapcore.DebugLevel: + return blue("DEBUG") + case zapcore.InfoLevel: + return green("INFO ") + case zapcore.WarnLevel: + return yellow("WARN ") + case zapcore.ErrorLevel: + return red("ERROR") + default: + return "?????" + } +} diff --git a/pkg/logger/sync.go b/pkg/logger/sync.go new file mode 100644 index 0000000..d8102a8 --- /dev/null +++ b/pkg/logger/sync.go @@ -0,0 +1,18 @@ +package logger + +import ( + "runtime" + "strings" + + "go.uber.org/zap" +) + +func Sync(l *zap.Logger) error { + err := l.Sync() + if err != nil { + if runtime.GOOS == "windows" && strings.Contains(err.Error(), "The handle is invalid") { + return nil + } + } + return err +} diff --git a/pkg/logger/trace.go b/pkg/logger/trace.go new file mode 100644 index 0000000..abf7d61 --- /dev/null +++ b/pkg/logger/trace.go @@ -0,0 +1,25 @@ +package logger + +import ( + "context" + + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +func WithTrace(ctx context.Context, l *zap.Logger) *zap.Logger { + span := trace.SpanFromContext(ctx) + if span == nil { + return l + } + + sc := span.SpanContext() + if !sc.IsValid() { + return l + } + + return l.With( + zap.String("trace_id", sc.TraceID().String()), + zap.String("span_id", sc.SpanID().String()), + ) +} diff --git a/pkg/utils/crypt.go b/pkg/utils/crypt.go index 9c203e0..d683727 100644 --- a/pkg/utils/crypt.go +++ b/pkg/utils/crypt.go @@ -12,10 +12,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -const ( - encryptionKeyString = "kmZxB1Ai5OMzJSLqXTtOv6b43RqHCg29" -) - // GenerateHash creates a bcrypt hash of the given target string. func GenerateHash(target string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(target), bcrypt.DefaultCost) @@ -27,9 +23,14 @@ func CompareHashAndPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -// GenerateToken creates an encrypted token containing the userID. -func GenerateToken(userID string) (string, error) { - block, err := aes.NewCipher([]byte(encryptionKeyString)) +// GenerateToken creates an AES-256-GCM encrypted token containing the userID. +// key must be exactly 32 bytes. +func GenerateToken(userID, key string) (string, error) { + k, err := parseKey(key) + if err != nil { + return "", err + } + block, err := aes.NewCipher(k) if err != nil { return "", err } @@ -50,14 +51,19 @@ func GenerateToken(userID string) (string, error) { return base64.RawURLEncoding.EncodeToString(tokenBytes), nil } -// DecryptToken decrypts token and returns the user_id -func DecryptToken(token string) (string, error) { +// DecryptToken decrypts a token and returns the user_id. +// key must be exactly 32 bytes. +func DecryptToken(token, key string) (string, error) { data, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return "", err } - block, err := aes.NewCipher([]byte(encryptionKeyString)) + k, err := parseKey(key) + if err != nil { + return "", err + } + block, err := aes.NewCipher(k) if err != nil { return "", err } @@ -81,3 +87,10 @@ func DecryptToken(token string) (string, error) { return string(plaintext), nil } + +func parseKey(key string) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key must be exactly 32 bytes for AES-256, got %d", len(key)) + } + return []byte(key), nil +} diff --git a/pkg/utils/storagex/gcs.go b/pkg/utils/storagex/gcs.go index b7f9452..9199c6c 100644 --- a/pkg/utils/storagex/gcs.go +++ b/pkg/utils/storagex/gcs.go @@ -11,7 +11,6 @@ import ( "cloud.google.com/go/storage" "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/pkg/errors" - "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -24,31 +23,24 @@ type gcsStorage struct { client *storage.Client secretAccessID string privateKey []byte - logger *utils.Logger + logger *zap.Logger provider string + m *metrics.Metrics } -func NewGCSStorage(ctx context.Context, projectID string) (StorageX, error) { +func NewGCSStorage(ctx context.Context, projectID string, m *metrics.Metrics) (StorageX, error) { client, err := storage.NewClient(ctx) if err != nil { return nil, err } - return &gcsStorage{ - client: client, - }, nil + return &gcsStorage{client: client, m: m}, nil } -func NewGCSStorageFromServiceAccountJSON(ctx context.Context, serviceAccountJSONPath string) (StorageX, error) { +func NewGCSStorageFromServiceAccountJSON(ctx context.Context, serviceAccountJSONPath string, m *metrics.Metrics, l *zap.Logger) (StorageX, error) { client, err := storage.NewClient(ctx, option.WithCredentialsFile(serviceAccountJSONPath)) if err != nil { return nil, err } - defer func(client *storage.Client) { - err := client.Close() - if err != nil { - utils.NewLogger().Error("Failed to close GCS client", zap.Error(err)) - } - }(client) data, err := os.ReadFile(serviceAccountJSONPath) if err != nil { @@ -74,7 +66,8 @@ func NewGCSStorageFromServiceAccountJSON(ctx context.Context, serviceAccountJSON client: client, secretAccessID: secretAccessID, privateKey: privateKey, - logger: utils.NewLogger(), + logger: l, + m: m, }, nil } @@ -290,29 +283,22 @@ func (g *gcsStorage) DeleteObject(ctx context.Context, bucket, key string) error // recordOperationMetrics records metrics for storage operations, including success/failure counts and operation duration. func (g *gcsStorage) recordOperationMetrics(ctx context.Context, operation string, success bool, duration time.Duration) { + if g.m == nil { + return + } status := "success" if !success { status = "error" } - attrs := []attribute.KeyValue{ attribute.String("storage.operation", operation), attribute.String("storage.provider", "gcs"), attribute.String("storage.status", status), } - if success { - if metrics.StorageOperationTotal != nil { - metrics.StorageOperationTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) - } + g.m.StorageOperationTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) } else { - if metrics.StorageOperationErrors != nil { - metrics.StorageOperationErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) - } + g.m.StorageOperationErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) } - - if metrics.StorageOperationDuration != nil { - metrics.StorageOperationDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(attrs...)) - } - + g.m.StorageOperationDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(attrs...)) } diff --git a/worker/consumer/config.py b/worker/consumer/config.py index 1190770..e6dacbc 100644 --- a/worker/consumer/config.py +++ b/worker/consumer/config.py @@ -1,5 +1,9 @@ import os -from dataclasses import dataclass +import socket +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional @dataclass @@ -10,7 +14,7 @@ class DatabaseConfig: password: str db_name: str ssl_mode: bool - ssl_cert_path: str = None + ssl_cert_path: Optional[str] = None pool_size: int = 10 pool_timeout: int = 30 max_retries: int = 5 @@ -66,9 +70,9 @@ class BucketConfig: region: str access_key: str secret_key: str - endpoint_url: str = None + endpoint_url: Optional[str] = None provider: str = "gcs" - sa_path: str = None # Service Account Path for GCS + sa_path: Optional[str] = None @staticmethod def from_env() -> "BucketConfig": @@ -76,12 +80,31 @@ def from_env() -> "BucketConfig": provider=os.getenv("BUCKET_PROVIDER", "gcs"), bucket_name=os.getenv("BUCKET_NAME", "media-bucket"), region=os.getenv("BUCKET_REGION", "us-east-1"), - access_key=os.getenv("BUCKET_ACCESS_KEY", "access_key"), - secret_key=os.getenv("BUCKET_SECRET_KEY", "secret_key"), + access_key=os.getenv("BUCKET_ACCESS_KEY", ""), + secret_key=os.getenv("BUCKET_SECRET_KEY", ""), endpoint_url=os.getenv("BUCKET_ENDPOINT_URL"), - sa_path=os.getenv( - "BUCKET_SA_PATH", ".secrets/aion-staging-d4d9b9c808ec.json" - ), + sa_path=os.getenv("BUCKET_SA_PATH"), + ) + + +@dataclass +class OtelConfig: + endpoint: str + service_name: str + service_version: str + deployment_env: str + tls_insecure: bool = True + instance_id: str = field(default_factory=socket.gethostname) + + @staticmethod + def from_env(service_name: str = "mpiper-worker", service_version: str = "dev") -> "OtelConfig": + return OtelConfig( + endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"), + service_name=os.getenv("SERVICE_NAME", service_name), + service_version=os.getenv("SERVICE_VERSION", service_version), + deployment_env=os.getenv("DEPLOYMENT_ENV", "development"), + tls_insecure=os.getenv("OTEL_TLS_INSECURE", "true").lower() == "true", + instance_id=os.getenv("HOSTNAME", socket.gethostname()), ) @@ -90,30 +113,65 @@ class WorkerConfig: database: DatabaseConfig redis: RedisConfig bucket: BucketConfig + otel: OtelConfig temp_dir: str stream_name: str worker_id: str + log_level: str + auto_migrate: bool + migrations_dir: str consumer_group: str = "worker-group" max_concurrent_jobs: int = 5 job_poll_interval: int = 10 @staticmethod def from_env() -> "WorkerConfig": - # get OS based temp dir if available curr_os = os.name if curr_os == "nt": temp_dir = os.getenv("TEMP", "C:\\Temp\\worker") else: temp_dir = os.getenv("TMPDIR", "/tmp/worker") + default_migrations_dir = str( + Path(__file__).resolve().parents[2] / "internal" / "database" / "migrations" + ) + return WorkerConfig( database=DatabaseConfig.from_env(), redis=RedisConfig.from_env(), bucket=BucketConfig.from_env(), - worker_id=os.getenv("WORKER_ID", "worker-1"), + otel=OtelConfig.from_env(), + worker_id=( + os.getenv("WORKER_ID") + or os.getenv("HOSTNAME") + or socket.gethostname() + or str(uuid.uuid4()) + ), max_concurrent_jobs=int(os.getenv("MAX_CONCURRENT_JOBS", "5")), job_poll_interval=int(os.getenv("JOB_POLL_INTERVAL", "10")), temp_dir=temp_dir, stream_name=os.getenv("STREAM_NAME", "media:jobs"), consumer_group=os.getenv("CONSUMER_GROUP", "worker-group"), + log_level=os.getenv("LOG_LEVEL", "INFO"), + auto_migrate=os.getenv("AUTO_MIGRATE", "false").lower() == "true", + migrations_dir=os.getenv("MIGRATIONS_DIR", default_migrations_dir), ) + + +# --- Singleton --- + +_instance: Optional[WorkerConfig] = None + + +def get_config() -> WorkerConfig: + """Return the process-level WorkerConfig singleton, initialising on first call.""" + global _instance + if _instance is None: + _instance = WorkerConfig.from_env() + return _instance + + +def init_config(cfg: WorkerConfig) -> None: + """Explicitly set the singleton (useful in tests or when config is built externally).""" + global _instance + _instance = cfg diff --git a/worker/consumer/main.py b/worker/consumer/main.py index f6845be..12b2159 100644 --- a/worker/consumer/main.py +++ b/worker/consumer/main.py @@ -2,13 +2,15 @@ import signal import time -from worker.consumer.config import WorkerConfig +from urllib.parse import quote_plus + +from worker.consumer.config import WorkerConfig, get_config from worker.consumer.consumer import Consumer from worker.consumer.db import PgPool +from worker.consumer.migrations import run_migrations from worker.storage.base import StorageX from worker.storage.gcs import GCSStorage from worker.utils import metrics as worker_metrics -from urllib.parse import quote_plus logger = logging.getLogger(__name__) @@ -17,7 +19,7 @@ def main(): # Initialise configurations, database connections, and consumer logger.info("Starting worker consumer...") - cfg = WorkerConfig.from_env() + cfg = get_config() storage = get_storage(cfg) password = quote_plus(cfg.database.password) @@ -25,6 +27,12 @@ def main(): f"postgresql://{cfg.database.user}:{password}" f"@{cfg.database.host}:{cfg.database.port}/{cfg.database.db_name}" ) + + if cfg.auto_migrate: + logger.info("AUTO_MIGRATE=true: running migrations") + run_migrations(dsn, migrations_dir=cfg.migrations_dir) + logger.info("Migrations applied successfully") + pg = PgPool(dsn=dsn) consumer = Consumer( pg_pool=pg, storage=storage, redis_url=cfg.redis.connection_string, cfg=cfg @@ -64,10 +72,7 @@ def _term(signum, frame): def get_storage(cfg: WorkerConfig) -> StorageX: - if cfg.bucket.provider == "s3": - return GCSStorage(cfg.bucket.bucket_name, cfg.bucket.sa_path) - else: - return GCSStorage(cfg.bucket.bucket_name, cfg.bucket.sa_path) + return GCSStorage(cfg.bucket.bucket_name, cfg.bucket.sa_path) if __name__ == "__main__": diff --git a/worker/utils/metrics.py b/worker/utils/metrics.py index 3ec98b7..82af1be 100644 --- a/worker/utils/metrics.py +++ b/worker/utils/metrics.py @@ -11,7 +11,6 @@ - Database operations """ -import os import socket from typing import Optional @@ -56,19 +55,15 @@ def init_metrics( service_name: str = "mpiper-worker", service_version: str = "dev", - endpoint: Optional[str] = None, + endpoint: str = "otel-collector:4317", + deployment_env: str = "development", + instance_id: Optional[str] = None, + tls_insecure: bool = True, ) -> None: """Initialize OpenTelemetry metrics with OTLP exporter. - - Parameters - ---------- - service_name : str - Name of the service for metrics identification - service_version : str - Version of the service - endpoint : Optional[str] - OTLP collector endpoint (e.g., "otel-collector:4317") - If not provided, reads from OTEL_EXPORTER_OTLP_ENDPOINT env var + + All parameters should be sourced from the centralised config (get_config().otel) + rather than read directly from environment variables. """ global _meter global queue_message_consumed, queue_message_failed, queue_processing_duration @@ -82,28 +77,21 @@ def init_metrics( logger.warning("Metrics already initialized") return - # Get endpoint from parameter or environment - if endpoint is None: - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317") - - # Strip any URL scheme (grpc://, http://, https://) if "://" in endpoint: endpoint = endpoint.split("://", 1)[1] logger.info(f"Initializing OpenTelemetry metrics with endpoint: {endpoint}") - # Create resource with service information resource = Resource.create({ - SERVICE_NAME: os.getenv("SERVICE_NAME", service_name), - SERVICE_VERSION: os.getenv("SERVICE_VERSION", service_version), - DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "development"), - SERVICE_INSTANCE_ID: os.getenv("HOSTNAME", socket.gethostname()), + SERVICE_NAME: service_name, + SERVICE_VERSION: service_version, + DEPLOYMENT_ENVIRONMENT: deployment_env, + SERVICE_INSTANCE_ID: instance_id or socket.gethostname(), }) - # Create OTLP exporter exporter = OTLPMetricExporter( endpoint=endpoint, - insecure=True, # TODO: Enable TLS in production + insecure=tls_insecure, ) # Create metric reader with 15-second export interval From 63f3e6cebbccce2eb4c5ebd1355f82abcf857b61 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Mon, 1 Jun 2026 16:47:54 +0530 Subject: [PATCH 02/19] feat: enhance migrations and other changes --- .claude/CLAUDE.md | 26 ++ .claude/agents/crawler.md | 40 +++ .claude/agents/interviewer.md | 30 ++ .claude/agents/reasoner.md | 47 +++ .claude/settings.json | 21 ++ .codegraph/.gitignore | 16 + .gitignore | 4 +- internal/database/migrate.go | 40 +++ .../migrations/000001_init_schema.down.sql | 5 + .../migrations/000001_init_schema.up.sql | 60 ++++ internal/middleware/metrics.go | 128 +++----- internal/repository/job_repo.go | 1 - internal/repository/variant_repo.go | 1 - internal/service/producer.go | 1 - pkg/utils/logger.go | 273 ------------------ pkg/utils/storagex/config.go | 10 - taskfile.yml | 31 +- worker/consumer/consumer.py | 2 + worker/consumer/migrations.py | 67 +++++ worker/processing/processor.py | 41 ++- worker/processing/videos.py | 28 +- worker/storage/s3.py | 52 ---- 22 files changed, 453 insertions(+), 471 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/agents/crawler.md create mode 100644 .claude/agents/interviewer.md create mode 100644 .claude/agents/reasoner.md create mode 100644 .claude/settings.json create mode 100644 .codegraph/.gitignore create mode 100644 internal/database/migrate.go create mode 100644 internal/database/migrations/000001_init_schema.down.sql create mode 100644 internal/database/migrations/000001_init_schema.up.sql delete mode 100644 internal/repository/job_repo.go delete mode 100644 internal/repository/variant_repo.go delete mode 100644 internal/service/producer.go delete mode 100644 pkg/utils/logger.go create mode 100644 worker/consumer/migrations.py delete mode 100644 worker/storage/s3.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..b0beb00 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,26 @@ +# Codebase Analyst Orchestrator + +You are a multi-agent orchestrator. Run these three phases in sequence. + +## Phase 1 — Context extraction +Spawn a subagent using agents/crawler.md with access to codegraph tools. +Tell it: "Analyse the full codebase at $PROJECT_ROOT and output context.json" +Wait for context.json to be written before proceeding. + +## Phase 2 — Technical assessment +Spawn a subagent using agents/reasoner.md. +Feed it the contents of context.json. +Tell it: "Produce assessment.json with your full technical report +and a list of questions_for_human." +Wait for assessment.json. + +## Phase 3 — Human dialogue +Spawn a subagent using agents/interviewer.md. +Feed it assessment.json. +It will ask the user the questions interactively in the terminal. +Collect answers into answers.json. + +## Phase 4 — Refined report +Re-invoke the reasoner subagent with both context.json and answers.json. +Tell it: "Revise your assessment with these human clarifications." +Write final output to ASSESSMENT.md. \ No newline at end of file diff --git a/.claude/agents/crawler.md b/.claude/agents/crawler.md new file mode 100644 index 0000000..07765e4 --- /dev/null +++ b/.claude/agents/crawler.md @@ -0,0 +1,40 @@ +# Crawler Agent + +You are a code context extractor. You have access to the codegraph plugin. + +## Your job +Use codegraph to traverse the codebase and extract structured context. +Do NOT summarize or assess quality — that is another agent's job. + +## Steps +1. Run: codegraph --ast --deps --symbols $PROJECT_ROOT +2. For each module/package codegraph identifies, extract: + - What it exports + - What it imports + - Key function signatures + - Any obvious patterns (factory, singleton, middleware chain, etc.) +3. Write everything to context.json + +## Output format +{ + "files": { + "src/auth/index.ts": { + "exports": ["authenticateUser", "AuthMiddleware"], + "imports": ["jsonwebtoken", "./db"], + "patterns": ["middleware", "singleton"], + "size_signals": {"loc": 340, "functions": 12, "classes": 1} + } + }, + "dependency_graph": { + "src/auth/index.ts": ["src/db/client.ts", "src/config.ts"] + }, + "entry_points": ["src/index.ts"], + "test_coverage_files": ["src/auth/auth.test.ts"] +} + +## Tools available +- codegraph (AST traversal, dependency graph, symbol extraction) +- Read files (for sampling source when codegraph output needs clarification) +- Write files (to produce context.json) + +Do not call any LLM for summarization. Just extract structure. \ No newline at end of file diff --git a/.claude/agents/interviewer.md b/.claude/agents/interviewer.md new file mode 100644 index 0000000..80e66e8 --- /dev/null +++ b/.claude/agents/interviewer.md @@ -0,0 +1,30 @@ +# Human-in-the-loop Interviewer + +You receive assessment.json. Your job is to have a conversation +with the developer to fill in the gaps the reasoner couldn't know. + +## How to run the interview + +Present each question conversationally, not as a numbered list. +React to their answers — if they reveal something that recontextualizes +an earlier question, say so and adjust. + +Example flow: + You: "The auth system uses session tokens stored in Redis — + was that a deliberate choice for horizontal scaling, + or did it just land that way?" + + Dev: "It landed that way, we don't actually need Redis." + + You: "Good to know — that simplifies things significantly. + On that note, what's your actual scale target? + That changes what we'd recommend for several things." + +## Also ask +At the end, always ask: +- "What does success look like in 6 months for this project?" +- "What's the one thing you most want to clean up but haven't had time for?" + +## Output +Write answers.json with the full Q&A captured as context +for the reasoner's second pass. \ No newline at end of file diff --git a/.claude/agents/reasoner.md b/.claude/agents/reasoner.md new file mode 100644 index 0000000..1d38e4c --- /dev/null +++ b/.claude/agents/reasoner.md @@ -0,0 +1,47 @@ +# Technical Reasoner Agent + +You are a senior staff engineer doing a deep technical audit. +You receive context.json from a codebase AST analysis. + +## Your assessment covers + +### 1. Project overview +One sharp paragraph. What does this actually do, who would use it, +what's the core technical bet. + +### 2. What's implemented well +Specific praise with file references. Things like: +- Clean separation of concerns +- Good use of a pattern for the domain +- Well-placed abstractions + +### 3. Over-engineering (be direct) +Call out things that add complexity without proportional value: +- Abstraction layers that don't earn their keep +- Premature generalization +- Framework sprawl +- "Enterprise patterns" on a 2-person codebase + +### 4. Needs improvement +Things that are actually wrong or risky: +- Missing error handling at I/O boundaries +- Hardcoded config that will break in production +- No observability +- Tight coupling that will hurt when requirements change + +### 5. Productization gaps +What needs to exist before this is a product, not a project: +- Auth/authz +- Rate limiting +- Secrets management +- Migration strategy +- Graceful degradation + +### 6. Questions for the human +Things you genuinely don't know without intent context. +Ask about WHY decisions were made, not just what they are. +Max 6 questions. Make them count. + +## Output +Write assessment.json with all sections as structured data. +Be specific: cite file names and function names. Never generalize. \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..dc8595f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,21 @@ +{ + "agents": { + "crawler": { + "model": "claude-haiku-4-5-20251001", + "note": "mostly tool calls, minimal reasoning needed" + }, + "reasoner": { + "model": "claude-opus-4-8", + "note": "deep assessment, use the best model" + }, + "interviewer": { + "model": "claude-sonnet-4-6", + "note": "conversational, needs to be good but not Opus" + }, + "orchestrator": { + "model": "claude-sonnet-4-6", + "note": "routing only, Sonnet is fine" + } + } +} + diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.gitignore b/.gitignore index 3f81082..658ca48 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,6 @@ tests/reports/ tests/logs/ coverage-reports/ coverage -API_DOCUMENTATION.md \ No newline at end of file +API_DOCUMENTATION.md + +.code_graph/ \ No newline at end of file diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..a05413c --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,40 @@ +package database + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// RunMigrations applies all pending up-migrations. Idempotent — safe to call on +// every startup when AUTO_MIGRATE=true. +func RunMigrations(db *sql.DB) error { + src, err := iofs.New(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("migration source: %w", err) + } + + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return fmt.Errorf("migration driver: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", src, "postgres", driver) + if err != nil { + return fmt.Errorf("migrate init: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + + return nil +} diff --git a/internal/database/migrations/000001_init_schema.down.sql b/internal/database/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..71b3f8b --- /dev/null +++ b/internal/database/migrations/000001_init_schema.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS variants.video; +DROP TABLE IF EXISTS variants.image; +DROP SCHEMA IF EXISTS variants; +DROP TABLE IF EXISTS assets; diff --git a/internal/database/migrations/000001_init_schema.up.sql b/internal/database/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..c0140ae --- /dev/null +++ b/internal/database/migrations/000001_init_schema.up.sql @@ -0,0 +1,60 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS assets ( + asset_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + original_url TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + content_hash TEXT, + canonical_asset_id UUID REFERENCES assets (asset_id) ON DELETE SET NULL, + error_reason TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE SCHEMA IF NOT EXISTS variants; + +CREATE TABLE IF NOT EXISTS variants.image ( + asset_id UUID NOT NULL REFERENCES assets (asset_id) ON DELETE CASCADE, + url TEXT NOT NULL, + role TEXT NOT NULL, + width INT, + height INT, + size_bytes BIGINT, + format TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (asset_id, role) +); + +CREATE TABLE IF NOT EXISTS variants.video ( + asset_id UUID NOT NULL REFERENCES assets (asset_id) ON DELETE CASCADE, + url TEXT NOT NULL, + role TEXT NOT NULL, + codec TEXT, + container TEXT, + resolution TEXT, + bitrate_kbps INT, + size_bytes BIGINT, + manifest_url TEXT, + duration_seconds INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (asset_id, role) +); + +CREATE TABLE IF NOT EXISTS jobs ( + job_id BIGSERIAL PRIMARY KEY, + asset_id UUID NOT NULL REFERENCES assets (asset_id) ON DELETE CASCADE, + type TEXT NOT NULL, + status TEXT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS jobs_asset_type_unique ON jobs (asset_id, type); diff --git a/internal/middleware/metrics.go b/internal/middleware/metrics.go index 9bd30a4..5e02ae4 100644 --- a/internal/middleware/metrics.go +++ b/internal/middleware/metrics.go @@ -27,102 +27,56 @@ func (w *metricsResponseWriter) Write(b []byte) (int, error) { return n, err } -// MetricsMiddleware records HTTP metrics for incoming requests. -// Safe for Prometheus cardinality and production traffic. -func MetricsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - route := chi.RouteContext(r.Context()).RoutePattern() - if route == "" { - route = "unknown" - } - - wrapped := &metricsResponseWriter{ - ResponseWriter: w, - statusCode: http.StatusOK, - } - - attrs := []attribute.KeyValue{ - attribute.String("http.method", r.Method), - attribute.String("http.route", route), - } - - // Active requests - if metrics.HTTPActiveRequests != nil { - metrics.HTTPActiveRequests.Add( - r.Context(), - 1, - metric.WithAttributes(attrs...), - ) - defer metrics.HTTPActiveRequests.Add( - r.Context(), - -1, - metric.WithAttributes(attrs...), - ) - } - - // Panic safety: still emit metrics - defer func() { - if rec := recover(); rec != nil { - wrapped.statusCode = http.StatusInternalServerError - recordHTTPMetrics(r, wrapped, start, attrs) - panic(rec) +func MetricsMiddleware(m *metrics.Metrics) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + route := chi.RouteContext(r.Context()).RoutePattern() + if route == "" { + route = "unknown" } - }() - - next.ServeHTTP(wrapped, r) - recordHTTPMetrics(r, wrapped, start, attrs) - }) -} + wrapped := &metricsResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} -func recordHTTPMetrics( - r *http.Request, - w *metricsResponseWriter, - start time.Time, - baseAttrs []attribute.KeyValue, -) { - duration := time.Since(start).Seconds() + attrs := []attribute.KeyValue{ + attribute.String("http.method", r.Method), + attribute.String("http.route", route), + } - attrs := append( - baseAttrs, - attribute.Int("http.status_code", w.statusCode), - ) + if m != nil { + m.HTTPActiveRequests.Add(r.Context(), 1, metric.WithAttributes(attrs...)) + defer m.HTTPActiveRequests.Add(r.Context(), -1, metric.WithAttributes(attrs...)) + } - if metrics.HTTPRequestDuration != nil { - metrics.HTTPRequestDuration.Record( - r.Context(), - duration, - metric.WithAttributes(attrs...), - ) + defer func() { + if rec := recover(); rec != nil { + wrapped.statusCode = http.StatusInternalServerError + recordHTTPMetrics(m, r, wrapped, start, attrs) + panic(rec) + } + }() + + next.ServeHTTP(wrapped, r) + recordHTTPMetrics(m, r, wrapped, start, attrs) + }) } +} - if metrics.HTTPRequestCount != nil { - metrics.HTTPRequestCount.Add( - r.Context(), - 1, - metric.WithAttributes(attrs...), - ) +func recordHTTPMetrics(m *metrics.Metrics, r *http.Request, w *metricsResponseWriter, start time.Time, baseAttrs []attribute.KeyValue) { + if m == nil { + return } + duration := time.Since(start).Seconds() + attrs := append(baseAttrs, attribute.Int("http.status_code", w.statusCode)) - if metrics.HTTPRequestSize != nil { - reqSize := r.ContentLength - if reqSize < 0 { - reqSize = 0 - } - metrics.HTTPRequestSize.Record( - r.Context(), - reqSize, - metric.WithAttributes(attrs...), - ) - } + m.HTTPRequestDuration.Record(r.Context(), duration, metric.WithAttributes(attrs...)) + m.HTTPRequestCount.Add(r.Context(), 1, metric.WithAttributes(attrs...)) - if metrics.HTTPResponseSize != nil { - metrics.HTTPResponseSize.Record( - r.Context(), - w.bytesWritten, - metric.WithAttributes(attrs...), - ) + reqSize := r.ContentLength + if reqSize < 0 { + reqSize = 0 } + m.HTTPRequestSize.Record(r.Context(), reqSize, metric.WithAttributes(attrs...)) + m.HTTPResponseSize.Record(r.Context(), w.bytesWritten, metric.WithAttributes(attrs...)) } diff --git a/internal/repository/job_repo.go b/internal/repository/job_repo.go deleted file mode 100644 index 50a4378..0000000 --- a/internal/repository/job_repo.go +++ /dev/null @@ -1 +0,0 @@ -package repository diff --git a/internal/repository/variant_repo.go b/internal/repository/variant_repo.go deleted file mode 100644 index 50a4378..0000000 --- a/internal/repository/variant_repo.go +++ /dev/null @@ -1 +0,0 @@ -package repository diff --git a/internal/service/producer.go b/internal/service/producer.go deleted file mode 100644 index 6d43c33..0000000 --- a/internal/service/producer.go +++ /dev/null @@ -1 +0,0 @@ -package service diff --git a/pkg/utils/logger.go b/pkg/utils/logger.go deleted file mode 100644 index 48ae1c3..0000000 --- a/pkg/utils/logger.go +++ /dev/null @@ -1,273 +0,0 @@ -package utils - -import ( - "encoding/json" - "os" - "runtime" - "strings" - "time" - - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -const ( - grey = "\033[90m" - reset = "\033[0m" -) - -// LoggerConfig holds all configuration for the logger -type LoggerConfig struct { - // LogLevel: DEBUG, INFO, WARN, ERROR, FATAL - LogLevel zapcore.Level - // EnableCaller adds caller information to logs - EnableCaller bool - // EnableStacktrace adds stacktraces for errors - EnableStacktrace bool - // StacktraceLevel is the minimum level for stacktraces - StacktraceLevel zapcore.Level - // TimeFormat for log timestamps (e.g., "15:04:05", "2006-01-02 15:04:05") - TimeFormat string - // PrintFieldsBelow prints fields in JSON format below the log message - PrintFieldsBelow bool - // UseColors enables colored output - UseColors bool - // UseJSON outputs logs in JSON format instead of console - UseJSON bool -} - -// DefaultConfig returns sensible defaults -func DefaultConfig() LoggerConfig { - return LoggerConfig{ - LogLevel: getLogLevelFromEnv(), - EnableCaller: false, - EnableStacktrace: false, - StacktraceLevel: zapcore.ErrorLevel, - TimeFormat: "2006-01-02 15:04:05.000", - PrintFieldsBelow: true, // Changed to true by default - UseColors: true, - UseJSON: false, - } -} - -// Logger wraps zap.Logger with convenience methods -type Logger struct { - *zap.Logger -} - -// NewLogger creates a new logger with default configuration -func NewLogger() *Logger { - return NewLoggerWithConfig(DefaultConfig()) -} - -// NewLoggerWithConfig creates a logger with custom configuration -func NewLoggerWithConfig(cfg LoggerConfig) *Logger { - encoderConfig := zapcore.EncoderConfig{ - TimeKey: "time", - LevelKey: "level", - NameKey: "logger", - CallerKey: "caller", - MessageKey: "msg", - StacktraceKey: "stacktrace", - LineEnding: zapcore.DefaultLineEnding, - EncodeTime: getTimeEncoder(cfg.TimeFormat), - EncodeDuration: zapcore.StringDurationEncoder, - EncodeCaller: zapcore.ShortCallerEncoder, - } - - var encoder zapcore.Encoder - if cfg.UseJSON { - encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder - encoder = zapcore.NewJSONEncoder(encoderConfig) - } else { - encoderConfig.ConsoleSeparator = " " - if cfg.UseColors { - encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder - } else { - encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder - } - encoder = zapcore.NewConsoleEncoder(encoderConfig) - } - - // Wrap encoder if PrintFieldsBelow is enabled - if cfg.PrintFieldsBelow && !cfg.UseJSON { - encoder = &fieldEncoder{ - Encoder: encoder, - printFields: true, - } - } - - core := zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), cfg.LogLevel) - - // Apply field filtering if needed - if cfg.PrintFieldsBelow && !cfg.UseJSON { - core = &fieldFilterCore{ - Core: core, - encoder: encoder.(*fieldEncoder), - output: zapcore.Lock(os.Stdout), - contextFields: []zapcore.Field{}, - } - } - - // Build options - var options []zap.Option - if cfg.EnableCaller { - options = append(options, zap.AddCaller()) - } - if cfg.EnableStacktrace { - options = append(options, zap.AddStacktrace(cfg.StacktraceLevel)) - } - - return &Logger{ - Logger: zap.New(core, options...), - } -} - -// fieldEncoder wraps an encoder to print fields separately -type fieldEncoder struct { - zapcore.Encoder - printFields bool -} - -func (e *fieldEncoder) Clone() zapcore.Encoder { - return &fieldEncoder{ - Encoder: e.Encoder.Clone(), - printFields: e.printFields, - } -} - -// fieldFilterCore intercepts Write calls to separate fields from main message -type fieldFilterCore struct { - zapcore.Core - encoder *fieldEncoder - output zapcore.WriteSyncer - contextFields []zapcore.Field -} - -func (c *fieldFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { - // Encode the entry WITHOUT fields for the main line - buf, err := c.encoder.Encoder.EncodeEntry(entry, nil) - if err != nil { - return err - } - - // Combine context fields and log-time fields - if c.encoder.printFields { - allFields := append(c.contextFields, fields...) - - if len(allFields) > 0 { - enc := zapcore.NewMapObjectEncoder() - for _, f := range allFields { - f.AddTo(enc) - } - - if len(enc.Fields) > 0 { - if jsonBytes, marshalErr := json.MarshalIndent(enc.Fields, "", " "); marshalErr == nil { - buf.AppendString(grey + string(jsonBytes) + reset + "\n") - } - } - } - } - - _, err = c.output.Write(buf.Bytes()) - buf.Free() - return err -} - -func (c *fieldFilterCore) With(fields []zapcore.Field) zapcore.Core { - // Clone and append new context fields - newContextFields := make([]zapcore.Field, len(c.contextFields)+len(fields)) - copy(newContextFields, c.contextFields) - copy(newContextFields[len(c.contextFields):], fields) - - return &fieldFilterCore{ - Core: c.Core.With(fields), - encoder: c.encoder, - output: c.output, - contextFields: newContextFields, - } -} - -func (c *fieldFilterCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { - if c.Enabled(entry.Level) { - return ce.AddCore(entry, c) - } - return ce -} - -// getTimeEncoder returns a time encoder based on format string -func getTimeEncoder(format string) zapcore.TimeEncoder { - if format == "" { - return zapcore.ISO8601TimeEncoder - } - return func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { - enc.AppendString(t.Format(format)) - } -} - -// getLogLevelFromEnv reads log level from LOG_LEVEL environment variable -func getLogLevelFromEnv() zapcore.Level { - level := strings.ToUpper(os.Getenv("LOG_LEVEL")) - switch level { - case "DEBUG": - return zapcore.DebugLevel - case "INFO": - return zapcore.InfoLevel - case "WARN", "WARNING": - return zapcore.WarnLevel - case "ERROR": - return zapcore.ErrorLevel - case "FATAL": - return zapcore.FatalLevel - default: - return zapcore.InfoLevel - } -} - -// Convenience methods - -// With accepts typed zap fields (compile-time safety). -func (l *Logger) With(fields ...zap.Field) *Logger { - return &Logger{Logger: l.Logger.With(fields...)} -} - -// WithField adds a single field to the logger context -func (l *Logger) WithField(key string, value interface{}) *Logger { - return &Logger{Logger: l.Logger.With(zap.Any(key, value))} -} - -// WithFields adds multiple fields to the logger context -func (l *Logger) WithFields(fields map[string]interface{}) *Logger { - zapFields := make([]zap.Field, 0, len(fields)) - for k, v := range fields { - zapFields = append(zapFields, zap.Any(k, v)) - } - return &Logger{Logger: l.Logger.With(zapFields...)} -} - -// WithError adds an error field to the logger context -func (l *Logger) WithError(err error) *Logger { - return &Logger{Logger: l.Logger.With(zap.Error(err))} -} - -// Close syncs the logger (should be called before application exit) -func (l *Logger) Close() error { - if err := l.Logger.Sync(); err != nil { - // On Windows syncing stdout/stderr can return "The handle is invalid" — ignore it. - if runtime.GOOS == "windows" && strings.Contains(err.Error(), "The handle is invalid") { - return nil - } - return err - } - return nil -} - -func CloseLogger(l *zap.Logger) error { - if err := l.Sync(); err != nil { - if runtime.GOOS == "windows" && strings.Contains(err.Error(), "The handle is invalid") { - return nil - } - return err - } - return nil -} diff --git a/pkg/utils/storagex/config.go b/pkg/utils/storagex/config.go index a3874a9..0e878b9 100644 --- a/pkg/utils/storagex/config.go +++ b/pkg/utils/storagex/config.go @@ -3,9 +3,7 @@ package storagex type Provider string const ( - AWSProvider Provider = "aws" GCPProvider Provider = "gcp" - AzureProvider Provider = "azure" MinIOProvider Provider = "minio" ) @@ -20,16 +18,8 @@ type Config struct { SecretAccessKey string SessionToken string // Optional, for temporary credentials - // AWS specific settings - AWSUseIAMRole bool // If true, use IAM Role for authentication - // GCP specific settings GCPProjectID string GCPServiceAccount string GCPPrivateKeyPath string - - // Azure specific settings - AzureAccountName string - AzureAccountKey string - AzureContainer string } diff --git a/taskfile.yml b/taskfile.yml index 0c99a93..7f92ab0 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -1,12 +1,11 @@ - -version: '3' +version: "3" # Variables vars: APP_NAME: mpiper BUILD_DIR: build MAIN_PATH: cmd/server/main.go - + # Dynamic variables BUILD_TIME: sh: | @@ -24,7 +23,7 @@ vars: sh: | git rev-parse --short HEAD 2>/dev/null || echo unknown VERSION: - sh: toml get --toml-path mpiper.toml version + sh: toml get --toml-path relay.toml version 2>/dev/null || echo "0.0.0" # Build flags LDFLAGS: >- @@ -162,18 +161,18 @@ tasks: docker-build-worker: desc: Build Docker image for worker cmds: - - docker build - --build-arg ENV=development - --build-arg VERSION={{.VERSION}} - --build-arg BUILD_TIME={{.BUILD_TIME}} + - docker build + --build-arg ENV=development + --build-arg VERSION={{.VERSION}} + --build-arg BUILD_TIME={{.BUILD_TIME}} --build-arg COMMIT_HASH={{.GIT_COMMIT}} - -t {{.APP_NAME}}-worker:{{.VERSION}} + -t {{.APP_NAME}}-worker:{{.VERSION}} -f deploy/docker/worker.dockerfile . docker-run-worker: desc: Run Docker container for worker cmds: - - docker run + - docker run --rm --name {{.APP_NAME}}_worker_dev --env-file .env.local @@ -189,10 +188,10 @@ tasks: docker-build: desc: Build Docker image cmds: - - docker build - --build-arg ENV=development - --build-arg VERSION={{.VERSION}} - --build-arg BUILD_TIME={{.BUILD_TIME}} + - docker build + --build-arg ENV=development + --build-arg VERSION={{.VERSION}} + --build-arg BUILD_TIME={{.BUILD_TIME}} --build-arg COMMIT_HASH={{.GIT_COMMIT}} --build-arg HOST=0.0.0.0 -t {{.APP_NAME}}:{{.VERSION}} . @@ -200,9 +199,9 @@ tasks: docker-run: desc: Run Docker container cmds: - - docker run + - docker run --rm - -p 5010:5010 + -p 5010:5010 --name {{.APP_NAME}}_dev --env-file .env.local -e DB_HOST=host.docker.internal diff --git a/worker/consumer/consumer.py b/worker/consumer/consumer.py index 4b46351..10a7288 100644 --- a/worker/consumer/consumer.py +++ b/worker/consumer/consumer.py @@ -230,6 +230,7 @@ def _handle_job(self, job_id: int, msg_id: str) -> None: "UPDATE jobs SET status = 'pending', last_error = %s, updated_at = now() WHERE job_id = %s", (str(exc), str(job_id)), ) + conn.commit() # Leave the Redis message unacked so it remains in the pending list. return @@ -244,6 +245,7 @@ def _handle_job(self, job_id: int, msg_id: str) -> None: "UPDATE assets SET status = 'ready', updated_at = now() WHERE asset_id = %s", (asset_id,), ) + conn.commit() # Acknowledge the Redis stream message. self.redis.xack(self.cfg.stream_name, self.cfg.consumer_group, msg_id) diff --git a/worker/consumer/migrations.py b/worker/consumer/migrations.py new file mode 100644 index 0000000..525503c --- /dev/null +++ b/worker/consumer/migrations.py @@ -0,0 +1,67 @@ +""" +Minimal SQL migration runner for the Python worker. + +Reads *.up.sql files from MIGRATIONS_DIR (or a given path) in version order, +tracks applied versions in a schema_migrations table, and applies any that +have not yet run. Safe to call on every startup. +""" + +import os +import re +from pathlib import Path + +import psycopg + +_TRACKING_TABLE = """ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +""" + + +def _migration_files(migrations_dir: Path): + """Return (version, path) pairs for *.up.sql files, sorted by version.""" + files = sorted(migrations_dir.glob("*.up.sql")) + result = [] + for f in files: + m = re.match(r"^(\d+)", f.name) + if m: + result.append((m.group(1), f)) + return result + + +def run_migrations(dsn: str, migrations_dir: str | None = None) -> None: + """Apply all pending migrations from migrations_dir against the given DSN.""" + if migrations_dir is None: + migrations_dir = os.getenv( + "MIGRATIONS_DIR", + str(Path(__file__).resolve().parents[2] / "internal" / "database" / "migrations"), + ) + + path = Path(migrations_dir) + if not path.is_dir(): + raise RuntimeError(f"Migrations directory not found: {path}") + + with psycopg.connect(dsn) as conn: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(_TRACKING_TABLE) + + pending = _migration_files(path) + + for version, sql_file in pending: + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM schema_migrations WHERE version = %s", (version,) + ) + if cur.fetchone(): + continue + + sql = sql_file.read_text() + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(sql) + cur.execute( + "INSERT INTO schema_migrations (version) VALUES (%s)", (version,) + ) diff --git a/worker/processing/processor.py b/worker/processing/processor.py index 1ff864a..2b78e86 100644 --- a/worker/processing/processor.py +++ b/worker/processing/processor.py @@ -156,26 +156,17 @@ def process_asset_dispatch( logger.info("Asset %s already in final state: %s", asset_id, status) return - # 3. Check for deduplication BEFORE expensive processing - if content_hash: - dedup_result = check_for_duplicate(content_hash, asset_id, pg_pool) - - if dedup_result == DedupResult.DUPLICATE_READY: - logger.info("Asset %s deduplicated successfully", asset_id) - return - elif dedup_result == DedupResult.DUPLICATE_PENDING: - raise RetryableException( - f"Canonical asset for {asset_id} not ready yet" - ) - - # 4. No duplicate found, proceed with processing + # 3. Proceed with processing + local_raw_file = None try: # Mark as processing with pg_pool.get_pg_conn() as conn: - conn.execute( + cur = conn.cursor() + cur.execute( "UPDATE assets SET status = %s WHERE asset_id = %s", (AssetStatus.PROCESSING.value, asset_id) ) + conn.commit() # Download raw file raw_key = f"media/raw/{asset_id}" @@ -188,6 +179,18 @@ def process_asset_dispatch( content_hash = compute_file_hash(local_raw_file) + # Check for duplicate using the actual downloaded file's hash + if content_hash: + dedup_result = check_for_duplicate(content_hash, asset_id, pg_pool) + + if dedup_result == DedupResult.DUPLICATE_READY: + logger.info("Asset %s deduplicated successfully", asset_id) + return + elif dedup_result == DedupResult.DUPLICATE_PENDING: + raise RetryableException( + f"Canonical asset for {asset_id} not ready yet" + ) + # Process based on type if typ == "image": process_image_file( @@ -203,11 +206,19 @@ def process_asset_dispatch( except Exception as e: logger.error("Failed to process asset %s: %s", asset_id, e, exc_info=True) with pg_pool.get_pg_conn() as conn: - conn.execute( + cur = conn.cursor() + cur.execute( "UPDATE assets SET status = %s, error_reason = %s WHERE asset_id = %s", (AssetStatus.FAILED.value, str(e), asset_id) ) + conn.commit() raise + finally: + if local_raw_file and os.path.exists(local_raw_file): + try: + os.unlink(local_raw_file) + except OSError: + logger.warning("Failed to delete temp file %s", local_raw_file) def clone_image_variants(cur, canonical_asset_id: str, new_asset_id: str) -> int: diff --git a/worker/processing/videos.py b/worker/processing/videos.py index 3cf9c62..af754b6 100644 --- a/worker/processing/videos.py +++ b/worker/processing/videos.py @@ -1,8 +1,10 @@ import hashlib import json -import subprocess -import os import logging +import os +import shutil +import subprocess +import tempfile from typing import Optional, Dict, Any logger = logging.getLogger("videos") @@ -76,15 +78,19 @@ def ensure_variant_exists( # Variant doesn't exist - generate it logger.info("Generating new variant %s for role=%s", variant_hash, role) - tmp_path = os.path.join(cfg.temp_dir, f"{variant_hash}.{ext}") + tmpdir = tempfile.mkdtemp(dir=cfg.temp_dir) + try: + tmp_path = os.path.join(tmpdir, f"{variant_hash}.{ext}") - # Call the generator function (passed in by caller) - metadata = generator_fn(tmp_path, params) + # Call the generator function (passed in by caller) + metadata = generator_fn(tmp_path, params) - # Upload to storage - with open(tmp_path, "rb") as f: - data = f.read() + # Upload to storage + with open(tmp_path, "rb") as f: + data = f.read() storage.upload_bytes(key, data, mime_type) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) # Store variant metadata if variant_type == "image": @@ -125,12 +131,6 @@ def ensure_variant_exists( ), ) - # Clean up temp file - try: - os.remove(tmp_path) - except OSError: - pass - return url diff --git a/worker/storage/s3.py b/worker/storage/s3.py deleted file mode 100644 index 46f1a3e..0000000 --- a/worker/storage/s3.py +++ /dev/null @@ -1,52 +0,0 @@ -import boto3 -from botocore.exceptions import ClientError -from typing import Optional, List, Dict, Any -from worker.storage.base import StorageX - - -class S3Storage(StorageX): - def __init__(self, bucket: str, region: str = "us-east-1"): - self.bucket = bucket - self.s3 = boto3.client("s3", region_name=region) - - def upload_bytes( - self, key: str, data: bytes, content_type: Optional[str] = None - ) -> str: - params = {"Bucket": self.bucket, "Key": key, "Body": data} - if content_type: - params["ContentType"] = content_type - - self.s3.put_object(**params) - - # simplest deterministic public URL (works if bucket is public) - return f"https://{self.bucket}.s3.amazonaws.com/{key}" - - def download_bytes(self, key: str) -> bytes: - resp = self.s3.get_object(Bucket=self.bucket, Key=key) - return resp["Body"].read() - - def delete(self, key: str) -> None: - self.s3.delete_object(Bucket=self.bucket, Key=key) - - def list_keys(self) -> List[str]: - paginator = self.s3.get_paginator("list_objects_v2") - page_iterator = paginator.paginate(Bucket=self.bucket) - - keys = [] - for page in page_iterator: - for obj in page.get("Contents", []): - keys.append(obj["Key"]) - return keys - - def get_metadata(self, key: str) -> Dict[str, Any]: - resp = self.s3.head_object(Bucket=self.bucket, Key=key) - return resp.get("Metadata", {}) - - def exists(self, key: str) -> bool: - try: - self.s3.head_object(Bucket=self.bucket, Key=key) - return True - except ClientError as e: - if e.response["Error"]["Code"] in ("404", "NoSuchKey"): - return False - raise From 59be446c21952f6626dbb5a0a242834b77bfad2b Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 3 Jun 2026 08:55:21 +0530 Subject: [PATCH 03/19] feat: taskfiles updated and added ci workflows --- .github/slack/failure.json | 98 ++++++++ .github/slack/success.json | 129 ++++++++++ .github/workflows/build-and-push.yml | 349 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 170 +++++++++++++ .github/workflows/release-lts.yml | 348 ++++++++++++++++++++++++++ .gitignore | 6 +- .version | 1 + go.mod | 6 +- go.sum | 41 +++- taskfile.yml | 247 ++----------------- taskfiles/build.yml | 87 +++++++ taskfiles/db.yml | 7 + taskfiles/dev.yml | 73 ++++++ taskfiles/docker.yml | 68 ++++++ taskfiles/lint.yml | 25 ++ taskfiles/test.yml | 20 ++ 16 files changed, 1435 insertions(+), 240 deletions(-) create mode 100644 .github/slack/failure.json create mode 100644 .github/slack/success.json create mode 100644 .github/workflows/build-and-push.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-lts.yml create mode 100644 .version create mode 100644 taskfiles/build.yml create mode 100644 taskfiles/db.yml create mode 100644 taskfiles/dev.yml create mode 100644 taskfiles/docker.yml create mode 100644 taskfiles/lint.yml create mode 100644 taskfiles/test.yml diff --git a/.github/slack/failure.json b/.github/slack/failure.json new file mode 100644 index 0000000..a6e8d9a --- /dev/null +++ b/.github/slack/failure.json @@ -0,0 +1,98 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "❌ Build Failed — ${GITHUB_REPOSITORY}", + "emoji": true + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n`${GITHUB_REF_NAME}`" + }, + { + "type": "mrkdwn", + "text": "*Commit:*\n" + }, + { + "type": "mrkdwn", + "text": "*Triggered by:*\n${GITHUB_ACTOR}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "⚠️ *The build failed during the CI/CD pipeline. Please review the logs and fix the issue before retrying.*" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*⏱️ Failed at:*\n${BUILD_TIMESTAMP}" + }, + { + "type": "mrkdwn", + "text": "*🔄 Run:*\n" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Failed Run", + "emoji": true + }, + "url": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}", + "style": "danger" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Logs", + "emoji": true + }, + "url": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/logs" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Commit", + "emoji": true + }, + "url": "https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "🔴 Build failed at ${BUILD_TIMESTAMP} on branch `${GITHUB_REF_NAME}`" + } + ] + } + ] +} diff --git a/.github/slack/success.json b/.github/slack/success.json new file mode 100644 index 0000000..c59a22a --- /dev/null +++ b/.github/slack/success.json @@ -0,0 +1,129 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🚀 Build Successful — ${GITHUB_REPOSITORY}", + "emoji": true + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n`${GITHUB_REF_NAME}`" + }, + { + "type": "mrkdwn", + "text": "*Commit:*\n" + }, + { + "type": "mrkdwn", + "text": "*Triggered by:*\n${GITHUB_ACTOR}" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*🐳 Container Image Details*" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Version Tag:*\n`${SEMVER_TAG}`" + }, + { + "type": "mrkdwn", + "text": "*SHA Tag:*\n`${SHA_TAG}`" + }, + { + "type": "mrkdwn", + "text": "*Image Digest:*\n`${IMAGE_DIGEST}`" + }, + { + "type": "mrkdwn", + "text": "*Environment:*\n`staging`" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*📦 Package URL:*\n" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Open Package", + "emoji": true + }, + "url": "https://github.com/users/${GITHUB_ACTOR}/packages/container/package/${GITHUB_REPO_NAME}" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*⏱️ Triggered at:*\n${BUILD_TIMESTAMP}" + }, + { + "type": "mrkdwn", + "text": "*🔄 Run:*\n" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Workflow Run", + "emoji": true + }, + "url": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}", + "style": "primary" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Commit", + "emoji": true + }, + "url": "https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "✅ Build completed successfully at ${BUILD_TIMESTAMP}" + } + ] + } + ] +} diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..a04bdc5 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,349 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - staging + +permissions: + contents: write + deployments: write + packages: write + pull-requests: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + image-digest: ${{ steps.build-and-push.outputs.digest }} + sha-tag: ${{ steps.vars.outputs.sha_tag }} + semver-tag: ${{ steps.vars.outputs.semver_tag }} + sha-only: ${{ steps.vars.outputs.sha_only }} + deployment-id: ${{ steps.create-deployment.outputs.deployment_id }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU (for cross-platform builds if needed) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from repo metadata + id: get_version + run: | + VERSION="$(tr -d '[:space:]' < .version 2>/dev/null || true)" + if [ -z "$VERSION" ]; then + VERSION="0.1.0" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Define image tags + id: vars + run: | + SHORT_SHA=${GITHUB_SHA::7} + VERSION=${{ steps.get_version.outputs.version }} + echo "sha_tag=${VERSION}-${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "semver_tag=${VERSION}" >> $GITHUB_OUTPUT + echo "sha_only=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "env_tag=staging" >> $GITHUB_OUTPUT + + - name: Set lowercase repository name + id: repo + run: echo "repository=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Extract build info + id: build_info + run: | + echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + echo "commit_sha=${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "commit_ref=${GITHUB_REF}" >> $GITHUB_OUTPUT + echo "build_user=${GITHUB_ACTOR}" >> $GITHUB_OUTPUT + echo "build_repo=${GITHUB_REPOSITORY}" >> $GITHUB_OUTPUT + echo "build_run_id=${GITHUB_RUN_ID}" >> $GITHUB_OUTPUT + echo "build_run_number=${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT + + - name: Create GitHub Deployment + uses: actions/github-script@v6 + id: create-deployment + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: 'staging', + auto_merge: false, + required_contexts: [], + description: 'Building & pushing Docker image to staging' + }); + core.setOutput('deployment_id', deployment.data.id); + core.info(`Created deployment ID: ${deployment.data.id}`); + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + file: deploy/docker/mpiper.dockerfile + push: true + build-args: | + ENV=staging + COMMIT_HASH=${{ steps.build_info.outputs.commit_sha }} + BUILD_TIME=${{ steps.build_info.outputs.build_date }} + VERSION=${{ steps.get_version.outputs.version }} + AUTHOR=${{ steps.build_info.outputs.build_user }} + tags: | + ghcr.io/${{ steps.repo.outputs.repository }}:${{ steps.vars.outputs.semver_tag }} + ghcr.io/${{ steps.repo.outputs.repository }}:${{ steps.vars.outputs.sha_tag }} + ghcr.io/${{ steps.repo.outputs.repository }}:${{ steps.vars.outputs.sha_only }} + ghcr.io/${{ steps.repo.outputs.repository }}:${{ steps.vars.outputs.env_tag }} + labels: | + org.opencontainers.image.source=${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.vars.outputs.semver_tag }} + + - name: Save image tags as artifact + run: | + echo "${{ steps.vars.outputs.sha_tag }}" > image_tag.txt + echo "${{ steps.vars.outputs.semver_tag }}" >> image_tag.txt + echo "${{ steps.vars.outputs.sha_only }}" >> image_tag.txt + echo "${{ steps.vars.outputs.env_tag }}" >> image_tag.txt + + - uses: actions/upload-artifact@v4 + with: + name: docker-image-tags + path: image_tag.txt + + - name: Create Git tag + uses: actions/github-script@v6 + continue-on-error: true + with: + script: | + const tag = '${{ steps.vars.outputs.sha_tag }}'; + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tag}`, + sha: context.sha + }); + core.info(`Created git tag: ${tag}`); + + - name: Write Job Summary + uses: actions/github-script@v6 + with: + script: | + const digest = '${{ steps.build-and-push.outputs.digest }}'; + const repo = 'ghcr.io/${{ steps.repo.outputs.repository }}'; + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + + await core.summary + .addHeading('🐳 Docker Build Summary') + .addTable([ + [{data: 'Property', header: true}, {data: 'Value', header: true}], + ['Repository', '${{ github.repository }}'], + ['Branch', '${{ github.ref_name }}'], + ['Commit', '${{ github.sha }}'], + ['Triggered by', '${{ github.actor }}'], + ['Version Tag', '${{ steps.vars.outputs.semver_tag }}'], + ['SHA Tag', '${{ steps.vars.outputs.sha_tag }}'], + ['Env Tag', 'staging'], + ['Digest', digest], + ]) + .addHeading('📦 Image Tags', 3) + .addList([ + `${repo}:${{ steps.vars.outputs.semver_tag }}`, + `${repo}:${{ steps.vars.outputs.sha_tag }}`, + `${repo}:${{ steps.vars.outputs.sha_only }}`, + `${repo}:staging`, + ]) + .addLink('🔗 View Workflow Run', runUrl) + .write(); + + notify-success: + needs: build-and-push + runs-on: ubuntu-latest + if: success() + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Slack tooling + run: sudo apt-get update && sudo apt-get install -y gettext-base jq + + - name: Download image tags + uses: actions/download-artifact@v4 + with: + name: docker-image-tags + + - name: Read image tags + id: read-tags + run: | + TAGS=$(cat image_tag.txt | sed 's/^/• `/' | sed 's/$/`/' | tr '\n' ' ') + echo "formatted-tags=${TAGS}" >> $GITHUB_OUTPUT + + - name: Update Deployment Status → success + uses: actions/github-script@v6 + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ needs.build-and-push.outputs.deployment-id }}, + state: 'success', + environment_url: 'https://staging.yourapp.com', + description: 'Image pushed: ${{ needs.build-and-push.outputs.sha-tag }}' + }); + + - name: Comment on PR with build info + uses: actions/github-script@v6 + with: + script: | + const digest = '${{ needs.build-and-push.outputs.image-digest }}'; + const shaTag = '${{ needs.build-and-push.outputs.sha-tag }}'; + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + const pkgUrl = `https://github.com/users/${{ github.actor }}/packages/container/package/${{ github.event.repository.name }}`; + + const body = [ + '## 🚀 Staging Build Successful', + '', + '| Property | Value |', + '|---|---|', + `| **Branch** | \`${{ github.ref_name }}\` |`, + `| **Commit** | \`${{ github.sha }}\` |`, + `| **Version Tag** | \`${{ needs.build-and-push.outputs.semver-tag }}\` |`, + `| **SHA Tag** | \`${shaTag}\` |`, + `| **Digest** | \`${digest}\` |`, + '', + `📦 [View Package Registry](${pkgUrl})`, + `🔄 [View Workflow Run #${{ github.run_number }}](${runUrl})`, + ].join('\n'); + + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${{ github.ref_name }}` + }); + + if (prs.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prs.data[0].number, + body + }); + core.info(`Commented on PR #${prs.data[0].number}`); + } else { + core.info('No open PR found — skipping PR comment.'); + } + + - name: Send Slack notification - Success + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_SHA_SHORT: ${{ needs.build-and-push.outputs.sha-only }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_REPO_NAME: ${{ github.event.repository.name }} + SEMVER_TAG: ${{ needs.build-and-push.outputs.semver-tag }} + SHA_TAG: ${{ needs.build-and-push.outputs.sha-tag }} + IMAGE_DIGEST: ${{ needs.build-and-push.outputs.image-digest }} + BUILD_TIMESTAMP: ${{ github.event.head_commit.timestamp }} + run: | + PAYLOAD=$(envsubst < .github/slack/success.json) + echo "$PAYLOAD" | jq . > /dev/null + curl -f -X POST \ + -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" + + notify-failure: + needs: build-and-push + runs-on: ubuntu-latest + if: failure() + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Slack tooling + run: sudo apt-get update && sudo apt-get install -y gettext-base jq + + - name: Update Deployment Status → failure + uses: actions/github-script@v6 + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ needs.build-and-push.outputs.deployment-id }}, + state: 'failure', + description: 'Docker build or push failed' + }); + + - name: Comment failure on PR + uses: actions/github-script@v6 + with: + script: | + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + + const body = [ + '## ❌ Staging Build Failed', + '', + '| Property | Value |', + '|---|---|', + `| **Branch** | \`${{ github.ref_name }}\` |`, + `| **Commit** | \`${{ github.sha }}\` |`, + `| **Triggered by** | ${{ github.actor }} |`, + '', + `🔴 [View Failed Run #${{ github.run_number }}](${runUrl})`, + ].join('\n'); + + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${{ github.ref_name }}` + }); + + if (prs.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prs.data[0].number, + body + }); + } + + - name: Send Slack notification - Failure + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_SHA_SHORT: ${{ needs.build-and-push.outputs.sha-only }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + BUILD_TIMESTAMP: ${{ github.event.head_commit.timestamp }} + run: | + PAYLOAD=$(envsubst < .github/slack/failure.json) + echo "$PAYLOAD" | jq . > /dev/null + curl -f -X POST \ + -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cfa6f70 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,170 @@ +name: CI + +on: + pull_request: + branches: + - main + - master + - staging + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + - unlabeled + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'lint')) }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting (gofmt) + shell: bash + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "These files are not gofmt-formatted:" + echo "$UNFORMATTED" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + install-mode: goinstall + args: --timeout=5m + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-unit-tests')) }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run unit tests with race and coverage + run: go test ./... -race -covermode=atomic -coverprofile=coverage.out -v + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: unit-coverage + path: coverage.out + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-integration-tests')) }} + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: mpiper_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d mpiper_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DB_HOST: localhost + DB_PORT: "5432" + DB_NAME: mpiper_test + DB_USER: postgres + DB_PASSWORD: postgres + REDIS_HOST: localhost + REDIS_PORT: "6379" + APP_ENV: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Wait for Postgres to be ready + run: | + for i in {1..20}; do + pg_isready -h localhost -p 5432 -U postgres -d mpiper_test && break + echo "Waiting for postgres..." + sleep 2 + done + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run integration tests + run: go test -tags=integration ./... -v + + build: + name: Build Validation + runs-on: ubuntu-latest + timeout-minutes: 20 + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-build') && contains(github.event.pull_request.labels.*.name, 'lint') && contains(github.event.pull_request.labels.*.name, 'run-unit-tests') && contains(github.event.pull_request.labels.*.name, 'run-integration-tests')) }} + needs: + - lint + - unit-tests + - integration-tests + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build mpiper binary + run: go build -v -o build/mpiper ./cmd/server/main.go + + - name: Validate Docker build + run: docker build -t mpiper-ci:${{ github.sha }} -f deploy/docker/mpiper.dockerfile . diff --git a/.github/workflows/release-lts.yml b/.github/workflows/release-lts.yml new file mode 100644 index 0000000..2fe73a1 --- /dev/null +++ b/.github/workflows/release-lts.yml @@ -0,0 +1,348 @@ +name: Release LTS Image + +on: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: read + deployments: write + issues: write + packages: write + pull-requests: write + +concurrency: + group: release-lts-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare-metadata: + name: Prepare Release Metadata + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + short-sha: ${{ steps.meta.outputs.short_sha }} + build-time: ${{ steps.meta.outputs.build_time }} + lts-tag: ${{ steps.meta.outputs.lts_tag }} + version-lts-tag: ${{ steps.meta.outputs.version_lts_tag }} + hash-lts-tag: ${{ steps.meta.outputs.hash_lts_tag }} + image-repo: ${{ steps.repo.outputs.repository }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read version and compute tags + id: meta + shell: bash + run: | + VERSION="$(tr -d '[:space:]' < .version 2>/dev/null || true)" + if [ -z "$VERSION" ]; then + VERSION="0.1.0" + fi + + SHORT_SHA="${GITHUB_SHA::7}" + BUILD_TIME="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT" + echo "lts_tag=lts" >> "$GITHUB_OUTPUT" + echo "version_lts_tag=${VERSION}-lts" >> "$GITHUB_OUTPUT" + echo "hash_lts_tag=${SHORT_SHA}-lts" >> "$GITHUB_OUTPUT" + + - name: Normalize repository name + id: repo + shell: bash + run: | + echo "repository=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + build-and-push-lts: + name: Build and Push LTS Image + runs-on: ubuntu-latest + needs: + - prepare-metadata + outputs: + image-digest: ${{ steps.build-and-push.outputs.digest }} + lts-tag: ${{ needs.prepare-metadata.outputs.lts-tag }} + version-lts-tag: ${{ needs.prepare-metadata.outputs.version-lts-tag }} + hash-lts-tag: ${{ needs.prepare-metadata.outputs.hash-lts-tag }} + deployment-id: ${{ steps.create-deployment.outputs.deployment_id }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Deployment + uses: actions/github-script@v6 + id: create-deployment + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: 'production', + auto_merge: false, + required_contexts: [], + description: 'Building and pushing Docker image to production' + }); + core.setOutput('deployment_id', deployment.data.id); + core.info(`Created deployment ID: ${deployment.data.id}`); + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + file: deploy/docker/mpiper.dockerfile + push: true + build-args: | + ENV=prod + COMMIT_HASH=${{ github.sha }} + BUILD_TIME=${{ needs.prepare-metadata.outputs.build-time }} + VERSION=${{ needs.prepare-metadata.outputs.version }} + AUTHOR=${{ github.actor }} + tags: | + ghcr.io/${{ needs.prepare-metadata.outputs.image-repo }}:${{ needs.prepare-metadata.outputs.lts-tag }} + ghcr.io/${{ needs.prepare-metadata.outputs.image-repo }}:${{ needs.prepare-metadata.outputs.version-lts-tag }} + ghcr.io/${{ needs.prepare-metadata.outputs.image-repo }}:${{ needs.prepare-metadata.outputs.hash-lts-tag }} + labels: | + org.opencontainers.image.source=${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.prepare-metadata.outputs.version }} + + - name: Save image tags as artifact + run: | + echo "${{ needs.prepare-metadata.outputs.lts-tag }}" > image_tag.txt + echo "${{ needs.prepare-metadata.outputs.version-lts-tag }}" >> image_tag.txt + echo "${{ needs.prepare-metadata.outputs.hash-lts-tag }}" >> image_tag.txt + + - uses: actions/upload-artifact@v4 + with: + name: docker-image-tags + path: image_tag.txt + + - name: Write release summary + uses: actions/github-script@v6 + with: + script: | + const digest = '${{ steps.build-and-push.outputs.digest }}'; + const repo = 'ghcr.io/${{ needs.prepare-metadata.outputs.image-repo }}'; + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + + await core.summary + .addHeading('LTS Release Published') + .addTable([ + [{ data: 'Property', header: true }, { data: 'Value', header: true }], + ['Repository', '${{ github.repository }}'], + ['Branch', '${{ github.ref_name }}'], + ['Commit', '${{ github.sha }}'], + ['Version', '${{ needs.prepare-metadata.outputs.version }}'], + ['Digest', digest] + ]) + .addHeading('Published Tags', 3) + .addList([ + `${repo}:${{ needs.prepare-metadata.outputs.lts-tag }}`, + `${repo}:${{ needs.prepare-metadata.outputs.version-lts-tag }}`, + `${repo}:${{ needs.prepare-metadata.outputs.hash-lts-tag }}` + ]) + .addLink('View Workflow Run', runUrl) + .write(); + + notify-success: + needs: + - prepare-metadata + - build-and-push-lts + runs-on: ubuntu-latest + if: success() + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Slack tooling + run: sudo apt-get update && sudo apt-get install -y gettext-base jq + + - name: Download image tags + uses: actions/download-artifact@v4 + with: + name: docker-image-tags + + - name: Read image tags + id: read-tags + run: | + TAGS=$(cat image_tag.txt | sed 's/^/• `/' | sed 's/$/`/' | tr '\n' ' ') + echo "formatted-tags=${TAGS}" >> $GITHUB_OUTPUT + + - name: Update Deployment Status to success + if: ${{ needs.build-and-push-lts.outputs.deployment-id != '' }} + uses: actions/github-script@v6 + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ needs.build-and-push-lts.outputs.deployment-id }}, + state: 'success', + environment_url: 'https://production.yourapp.com', + description: 'Image pushed: ${{ needs.build-and-push-lts.outputs.hash-lts-tag }}' + }); + + - name: Comment on PR with build info + uses: actions/github-script@v6 + with: + script: | + const digest = '${{ needs.build-and-push-lts.outputs.image-digest }}'; + const shaTag = '${{ needs.build-and-push-lts.outputs.hash-lts-tag }}'; + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + const pkgUrl = `https://github.com/users/${{ github.actor }}/packages/container/package/${{ github.event.repository.name }}`; + + const body = [ + '## Production LTS Release Successful', + '', + '| Property | Value |', + '|---|---|', + `| **Branch** | \`${{ github.ref_name }}\` |`, + `| **Commit** | \`${{ github.sha }}\` |`, + `| **Version-LTS Tag** | \`${{ needs.build-and-push-lts.outputs.version-lts-tag }}\` |`, + `| **Hash-LTS Tag** | \`${shaTag}\` |`, + `| **Digest** | \`${digest}\` |`, + '', + `📦 [View Package Registry](${pkgUrl})`, + `🔄 [View Workflow Run #${{ github.run_number }}](${runUrl})`, + ].join('\n'); + + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${{ github.ref_name }}` + }); + + if (prs.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prs.data[0].number, + body + }); + core.info(`Commented on PR #${prs.data[0].number}`); + } else { + core.info('No open PR found, skipping PR comment.'); + } + + - name: Send Slack notification - Success + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_SHA_SHORT: ${{ needs.prepare-metadata.outputs.short-sha }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_REPO_NAME: ${{ github.event.repository.name }} + SEMVER_TAG: ${{ needs.build-and-push-lts.outputs.version-lts-tag }} + SHA_TAG: ${{ needs.build-and-push-lts.outputs.hash-lts-tag }} + IMAGE_DIGEST: ${{ needs.build-and-push-lts.outputs.image-digest }} + BUILD_TIMESTAMP: ${{ needs.prepare-metadata.outputs.build-time }} + run: | + PAYLOAD=$(envsubst < .github/slack/success.json) + echo "$PAYLOAD" | jq . > /dev/null + curl -f -X POST \ + -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" + + notify-failure: + needs: + - prepare-metadata + - build-and-push-lts + runs-on: ubuntu-latest + if: failure() + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Slack tooling + run: sudo apt-get update && sudo apt-get install -y gettext-base jq + + - name: Update Deployment Status to failure + if: ${{ needs.build-and-push-lts.outputs.deployment-id != '' }} + uses: actions/github-script@v6 + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ needs.build-and-push-lts.outputs.deployment-id }}, + state: 'failure', + description: 'Docker production LTS build or push failed' + }); + + - name: Comment failure on PR + uses: actions/github-script@v6 + with: + script: | + const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`; + + const body = [ + '## Production LTS Release Failed', + '', + '| Property | Value |', + '|---|---|', + `| **Branch** | \`${{ github.ref_name }}\` |`, + `| **Commit** | \`${{ github.sha }}\` |`, + `| **Triggered by** | ${{ github.actor }} |`, + '', + `🔴 [View Failed Run #${{ github.run_number }}](${runUrl})`, + ].join('\n'); + + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${{ github.ref_name }}` + }); + + if (prs.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prs.data[0].number, + body + }); + } + + - name: Send Slack notification - Failure + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_SHA_SHORT: ${{ needs.prepare-metadata.outputs.short-sha }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + BUILD_TIMESTAMP: ${{ needs.prepare-metadata.outputs.build-time }} + run: | + PAYLOAD=$(envsubst < .github/slack/failure.json) + echo "$PAYLOAD" | jq . > /dev/null + curl -f -X POST \ + -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 658ca48..674cf51 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,8 @@ coverage-reports/ coverage API_DOCUMENTATION.md -.code_graph/ \ No newline at end of file +.code_graph/ + +.kimchi/ + +.codegraph/ \ No newline at end of file diff --git a/.version b/.version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +0.1.0 diff --git a/go.mod b/go.mod index 0611d2f..fc97cc8 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -53,12 +53,12 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 // indirect + go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.47.0 // indirect diff --git a/go.sum b/go.sum index 5b08213..95d8d55 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= @@ -30,6 +32,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -40,10 +44,24 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= @@ -67,6 +85,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -93,6 +113,18 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -113,7 +145,6 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= @@ -127,7 +158,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/log/logtest v0.20.0 h1:+tsZVE15N+RWyN9lUzsRyw7hMZXNMepGu105Eim82/k= +go.opentelemetry.io/otel/log/logtest v0.20.0/go.mod h1:zS9Ryx9RrEAG2tgapMBSvacwhVSSOGSaSiWWgW3NPlQ= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= @@ -135,7 +167,6 @@ go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKz go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= @@ -145,10 +176,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/taskfile.yml b/taskfile.yml index 7f92ab0..c49f5c7 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -1,243 +1,28 @@ -version: "3" +version: '3' + +dotenv: [".env", ".env.local"] + +includes: + build: ./taskfiles/build.yml + dev: ./taskfiles/dev.yml + db: ./taskfiles/db.yml + docker: ./taskfiles/docker.yml + lint: ./taskfiles/lint.yml + test: ./taskfiles/test.yml -# Variables vars: APP_NAME: mpiper BUILD_DIR: build MAIN_PATH: cmd/server/main.go - # Dynamic variables - BUILD_TIME: - sh: | - # Use PowerShell when available (Windows / pwsh), otherwise fallback to UTC date - if command -v pwsh >/dev/null 2>&1 || command -v powershell >/dev/null 2>&1; then - if command -v pwsh >/dev/null 2>&1; then - pwsh -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'" - else - powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'" - fi - else - date -u +%Y-%m-%dT%H:%M:%SZ - fi - GIT_COMMIT: - sh: | - git rev-parse --short HEAD 2>/dev/null || echo unknown - VERSION: - sh: toml get --toml-path relay.toml version 2>/dev/null || echo "0.0.0" - - # Build flags - LDFLAGS: >- - -X 'main.Version={{.VERSION}}' - -X 'main.BuildTime={{.BUILD_TIME}}' - -X 'main.CommitHash={{.GIT_COMMIT}}' - -# Tasks tasks: default: - desc: Show available tasks - cmds: - - task --list - - install: - desc: Install dependencies - cmds: - - go mod download - - go mod tidy - - run: - desc: Run the application (development) - cmds: - - air -c .air.toml - - dev: - desc: Run the application (development) - env: - LOG_LEVEL: DEBUG - ENV: development - cmds: - - go run -ldflags="{{.LDFLAGS}} -X 'main.Env=development'" {{.MAIN_PATH}} - - staging: - desc: Run the application (staging) - env: - LOG_LEVEL: INFO - ENV: staging - cmds: - - go run -ldflags="{{.LDFLAGS}}" {{.MAIN_PATH}} - - prod: - desc: Run the application (production) - env: - LOG_LEVEL: WARN - ENV: production - cmds: - - go run -ldflags="{{.LDFLAGS}}" {{.MAIN_PATH}} - - build: - desc: Build the application - cmds: - - echo "Building {{.APP_NAME}} version {{.VERSION}}" - - mkdir -p {{.BUILD_DIR}} - - CGO_ENABLED=0 go build -ldflags="-w -s {{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}.exe {{.MAIN_PATH}} - - echo "Build completed {{.BUILD_DIR}}/{{.APP_NAME}}" - - run-binary: - desc: Run the built binary - env: - LOG_LEVEL: INFO - ENV: production - cmds: - - ./{{.BUILD_DIR}}/{{.APP_NAME}}.exe - - build-prod: - desc: Build the application for production - env: - ENV: production - cmds: - - task build - - build-all: - desc: Build for all platforms - cmds: - - task: build-windows - - task: build-linux - - task: build-mac - - build-windows: - desc: Build for Windows - env: - GOOS: windows - GOARCH: amd64 - cmds: - - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-windows-amd64.exe {{.MAIN_PATH}} - - build-linux: - desc: Build for Linux - env: - GOOS: linux - GOARCH: amd64 - cmds: - - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-linux-amd64 {{.MAIN_PATH}} - - build-mac: - desc: Build for macOS - env: - GOOS: darwin - GOARCH: amd64 - cmds: - - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-darwin-amd64 {{.MAIN_PATH}} - - test: - desc: Run tests - cmds: - - gotestsum --format testname --format-icons codicons -- -v {{.CLI_ARGS}} - - test-coverage: - desc: Run tests with coverage - cmds: - - go test -v -coverprofile=coverage.out ./... - - go tool cover -html=coverage.out -o coverage.html - - echo "✅ Coverage report - coverage.html" - - lint: - desc: Run linter - cmds: - - golangci-lint run ./... - - fmt: - desc: Format code - cmds: - - go fmt ./... - - goimports -w . - - clean: - desc: Clean build artifacts - cmds: - - powershell -Command "if (Test-Path {{.BUILD_DIR}}) { Remove-Item -Recurse -Force {{.BUILD_DIR}} }" - - powershell -Command "if (Test-Path coverage.out) { Remove-Item coverage.out }" - - powershell -Command "if (Test-Path coverage.html) { Remove-Item coverage.html }" - - echo "✅ Cleaned build artifacts" - - docker-build-worker: - desc: Build Docker image for worker - cmds: - - docker build - --build-arg ENV=development - --build-arg VERSION={{.VERSION}} - --build-arg BUILD_TIME={{.BUILD_TIME}} - --build-arg COMMIT_HASH={{.GIT_COMMIT}} - -t {{.APP_NAME}}-worker:{{.VERSION}} - -f deploy/docker/worker.dockerfile . - - docker-run-worker: - desc: Run Docker container for worker - cmds: - - docker run - --rm - --name {{.APP_NAME}}_worker_dev - --env-file .env.local - -v .secrets/:/app/.secrets:ro - -e DB_HOST=host.docker.internal - -e ENV=development - -e LOG_LEVEL=DEBUG - -e VERSION={{.VERSION}} - -e BUILD_TIME={{.BUILD_TIME}} - -e COMMIT_HASH={{.GIT_COMMIT}} - {{.APP_NAME}}-worker:{{.VERSION}} - - docker-build: - desc: Build Docker image - cmds: - - docker build - --build-arg ENV=development - --build-arg VERSION={{.VERSION}} - --build-arg BUILD_TIME={{.BUILD_TIME}} - --build-arg COMMIT_HASH={{.GIT_COMMIT}} - --build-arg HOST=0.0.0.0 - -t {{.APP_NAME}}:{{.VERSION}} . - - docker-run: - desc: Run Docker container - cmds: - - docker run - --rm - -p 5010:5010 - --name {{.APP_NAME}}_dev - --env-file .env.local - -e DB_HOST=host.docker.internal - -e ENV=development - -e HOST=0.0.0.0 - -e LOG_LEVEL=DEBUG - -e VERSION={{.VERSION}} - -e BUILD_TIME={{.BUILD_TIME}} - -e COMMIT_HASH={{.GIT_COMMIT}} - {{.APP_NAME}}:{{.VERSION}} - - deps-update: - desc: Update dependencies - cmds: - - go get -u ./... - - go mod tidy - - version: - desc: Show version information - cmds: - - echo "App Name {{.APP_NAME}}" - - echo "Version {{.VERSION}}" - - echo "Build Time {{.BUILD_TIME}}" - - echo "Git Commit {{.GIT_COMMIT}}" - - all: - desc: Clean, build, and test cmds: - - task: clean - - task: build - - task: test + - task: dev:run ci: - desc: Run CI pipeline + desc: Run full CI pipeline cmds: - - task: fmt - - task: lint - - task: test - - task: build + - task: lint:all + - task: test:all + - task: build:prod \ No newline at end of file diff --git a/taskfiles/build.yml b/taskfiles/build.yml new file mode 100644 index 0000000..6469aca --- /dev/null +++ b/taskfiles/build.yml @@ -0,0 +1,87 @@ +version: '3' + +vars: + BUILD_TIME: + sh: TZ=Asia/Kolkata date +%Y-%m-%dT%H:%M:%S%z + + GIT_COMMIT: + sh: | + git rev-parse --short HEAD 2>/dev/null || echo "dev" + + VERSION: + sh: toml get --toml-path mpiper.toml version 2>/dev/null || echo "0.0.0" + + AUTHOR: + sh: toml get --toml-path mpiper.toml author.name 2>/dev/null || echo "Unknown" + + LDFLAGS: >- + -X 'main.Version={{.VERSION}}' + -X 'main.BuildTime={{.BUILD_TIME}}' + -X 'main.CommitHash={{.GIT_COMMIT}}' + -X 'main.Author={{.AUTHOR}}' + +tasks: + dev: + desc: Build debug binary + cmds: + - go build -o {{.BUILD_DIR}}/{{.APP_NAME}} {{.MAIN_PATH}} + + prod: + desc: Build optimized binary + cmds: + - go build -ldflags="{{.LDFLAGS}} -s -w" -o {{.BUILD_DIR}}/{{.APP_NAME}} {{.MAIN_PATH}} + + clean: + desc: Clean build artifacts + cmds: + - powershell -Command "if (Test-Path {{.BUILD_DIR}}) { Remove-Item -Recurse -Force {{.BUILD_DIR}} }" + - powershell -Command "if (Test-Path coverage.out) { Remove-Item coverage.out }" + - powershell -Command "if (Test-Path coverage.html) { Remove-Item coverage.html }" + - echo "✅ Cleaned build artifacts" + + run-binary: + desc: Run the built binary + env: + LOG_LEVEL: INFO + ENV: production + cmds: + - ./{{.BUILD_DIR}}/{{.APP_NAME}} + + build-all: + desc: Build for all platforms + cmds: + - task: build-windows + - task: build-linux + - task: build-mac + + build-windows: + desc: Build for Windows + env: + GOOS: windows + GOARCH: amd64 + cmds: + - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-windows-amd64.exe {{.MAIN_PATH}} + + build-linux: + desc: Build for Linux + env: + GOOS: linux + GOARCH: amd64 + cmds: + - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-linux-amd64 {{.MAIN_PATH}} + + build-mac: + desc: Build for macOS + env: + GOOS: darwin + GOARCH: amd64 + cmds: + - go build -ldflags="{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.APP_NAME}}-darwin-amd64 {{.MAIN_PATH}} + + version: + desc: Show version information + cmds: + - echo "App Name {{.APP_NAME}}" + - echo "Version {{.VERSION}}" + - echo "Build Time {{.BUILD_TIME}}" + - echo "Git Commit {{.GIT_COMMIT}}" \ No newline at end of file diff --git a/taskfiles/db.yml b/taskfiles/db.yml new file mode 100644 index 0000000..58b43c9 --- /dev/null +++ b/taskfiles/db.yml @@ -0,0 +1,7 @@ +version: '3' + +tasks: + placeholder: + desc: Database tasks placeholder - will be populated when migrations are added + cmds: + - echo "No database migration tasks configured yet" \ No newline at end of file diff --git a/taskfiles/dev.yml b/taskfiles/dev.yml new file mode 100644 index 0000000..480a190 --- /dev/null +++ b/taskfiles/dev.yml @@ -0,0 +1,73 @@ +version: '3' + +vars: + BUILD_TIME: + sh: TZ=Asia/Kolkata date +%Y-%m-%dT%H:%M:%S%z + + GIT_COMMIT: + sh: | + git rev-parse --short HEAD 2>/dev/null || echo "dev" + + VERSION: + sh: toml get --toml-path mpiper.toml version 2>/dev/null || echo "0.0.0" + + AUTHOR: + sh: toml get --toml-path mpiper.toml author.name 2>/dev/null || echo "Unknown" + + LDFLAGS: >- + -X 'main.Version={{.VERSION}}' + -X 'main.BuildTime={{.BUILD_TIME}}' + -X 'main.CommitHash={{.GIT_COMMIT}}' + -X 'main.Author={{.AUTHOR}}' + +tasks: + run: + desc: Run app + cmds: + - go run -ldflags="{{.LDFLAGS}} -s -w" {{.MAIN_PATH}} + + watch: + desc: Hot reload using air + cmds: + - air -c .air.toml + + start: + desc: Run built binary + cmds: + - ./{{.BUILD_DIR}}/{{.APP_NAME}} + + install: + desc: Install dependencies + cmds: + - go mod download + - go mod tidy + + deps-update: + desc: Update dependencies + cmds: + - go get -u ./... + - go mod tidy + + develop: + desc: Run the application (development) + env: + LOG_LEVEL: DEBUG + ENV: development + cmds: + - go run -ldflags="{{.LDFLAGS}} -X 'main.Env=development'" {{.MAIN_PATH}} + + staging: + desc: Run the application (staging) + env: + LOG_LEVEL: INFO + ENV: staging + cmds: + - go run -ldflags="{{.LDFLAGS}}" {{.MAIN_PATH}} + + prod: + desc: Run the application (production) + env: + LOG_LEVEL: WARN + ENV: production + cmds: + - go run -ldflags="{{.LDFLAGS}}" {{.MAIN_PATH}} \ No newline at end of file diff --git a/taskfiles/docker.yml b/taskfiles/docker.yml new file mode 100644 index 0000000..4e820cd --- /dev/null +++ b/taskfiles/docker.yml @@ -0,0 +1,68 @@ +version: '3' + +vars: + BUILD_TIME: + sh: TZ=Asia/Kolkata date +%Y-%m-%dT%H:%M:%S%z + + GIT_COMMIT: + sh: | + git rev-parse --short HEAD 2>/dev/null || echo "dev" + + VERSION: + sh: toml get --toml-path mpiper.toml version 2>/dev/null || echo "0.0.0" + +tasks: + build-worker: + desc: Build Docker image for worker + cmds: + - docker build + --build-arg ENV=development + --build-arg VERSION={{.VERSION}} + --build-arg BUILD_TIME={{.BUILD_TIME}} + --build-arg COMMIT_HASH={{.GIT_COMMIT}} + -t {{.APP_NAME}}-worker:{{.VERSION}} + -f deploy/docker/worker.dockerfile . + + run-worker: + desc: Run Docker container for worker + cmds: + - docker run + --rm + --name {{.APP_NAME}}_worker_dev + --env-file .env.local + -v .secrets/:/app/.secrets:ro + -e DB_HOST=host.docker.internal + -e ENV=development + -e LOG_LEVEL=DEBUG + -e VERSION={{.VERSION}} + -e BUILD_TIME={{.BUILD_TIME}} + -e COMMIT_HASH={{.GIT_COMMIT}} + {{.APP_NAME}}-worker:{{.VERSION}} + + build: + desc: Build Docker image + cmds: + - docker build + --build-arg ENV=development + --build-arg VERSION={{.VERSION}} + --build-arg BUILD_TIME={{.BUILD_TIME}} + --build-arg COMMIT_HASH={{.GIT_COMMIT}} + --build-arg HOST=0.0.0.0 + -t {{.APP_NAME}}:{{.VERSION}} . + + run: + desc: Run Docker container + cmds: + - docker run + --rm + -p 5010:5010 + --name {{.APP_NAME}}_dev + --env-file .env.local + -e DB_HOST=host.docker.internal + -e ENV=development + -e HOST=0.0.0.0 + -e LOG_LEVEL=DEBUG + -e VERSION={{.VERSION}} + -e BUILD_TIME={{.BUILD_TIME}} + -e COMMIT_HASH={{.GIT_COMMIT}} + {{.APP_NAME}}:{{.VERSION}} \ No newline at end of file diff --git a/taskfiles/lint.yml b/taskfiles/lint.yml new file mode 100644 index 0000000..d05a884 --- /dev/null +++ b/taskfiles/lint.yml @@ -0,0 +1,25 @@ +version: '3' + +tasks: + fmt: + cmds: + - go fmt ./... + + vet: + cmds: + - go vet ./... + + install: + desc: Install golangci-lint locally + cmds: + - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + lint: + cmds: + - golangci-lint run ./... + + all: + cmds: + - task: fmt + - task: vet + - task: lint \ No newline at end of file diff --git a/taskfiles/test.yml b/taskfiles/test.yml new file mode 100644 index 0000000..f551457 --- /dev/null +++ b/taskfiles/test.yml @@ -0,0 +1,20 @@ +version: '3' + +tasks: + unit: + desc: Run unit tests + cmds: + - gotestsum --format testname --format-icons codicons -- -v {{.CLI_ARGS}} + + race: + cmds: + - go test ./... -race + + coverage: + cmds: + - go test ./... -coverprofile=coverage.out + + all: + cmds: + - task: unit + - task: race \ No newline at end of file From b0a931d5fd37845123d979d0b93ca419fe3d2b27 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 3 Jun 2026 09:00:03 +0530 Subject: [PATCH 04/19] fix: add guard against empty webhook and bump go build version --- .github/workflows/build-and-push.yml | 2 ++ .github/workflows/release-lts.yml | 2 ++ deploy/docker/mpiper.dockerfile | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index a04bdc5..13370e7 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -249,6 +249,7 @@ jobs: } - name: Send Slack notification - Success + if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -330,6 +331,7 @@ jobs: } - name: Send Slack notification - Failure + if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/release-lts.yml b/.github/workflows/release-lts.yml index 2fe73a1..96a7cdd 100644 --- a/.github/workflows/release-lts.yml +++ b/.github/workflows/release-lts.yml @@ -246,6 +246,7 @@ jobs: } - name: Send Slack notification - Success + if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -329,6 +330,7 @@ jobs: } - name: Send Slack notification - Failure + if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/deploy/docker/mpiper.dockerfile b/deploy/docker/mpiper.dockerfile index fd1f024..06ce606 100644 --- a/deploy/docker/mpiper.dockerfile +++ b/deploy/docker/mpiper.dockerfile @@ -1,5 +1,5 @@ # Stage 1: Builder - Build the Go binary -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder # Install build dependencies RUN apk add --no-cache git ca-certificates tzdata From eb41510ff5b5439aafccf7d0a933449bfbf61490 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 3 Jun 2026 09:04:06 +0530 Subject: [PATCH 05/19] fix: update secret check --- .github/workflows/build-and-push.yml | 2 -- .github/workflows/release-lts.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 13370e7..a04bdc5 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -249,7 +249,6 @@ jobs: } - name: Send Slack notification - Success - if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -331,7 +330,6 @@ jobs: } - name: Send Slack notification - Failure - if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/release-lts.yml b/.github/workflows/release-lts.yml index 96a7cdd..2fe73a1 100644 --- a/.github/workflows/release-lts.yml +++ b/.github/workflows/release-lts.yml @@ -246,7 +246,6 @@ jobs: } - name: Send Slack notification - Success - if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -330,7 +329,6 @@ jobs: } - name: Send Slack notification - Failure - if: ${{ secrets.SLACK_WEBHOOK_URL != '' }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} GITHUB_REPOSITORY: ${{ github.repository }} From 9f623172a539a129584a85ccd512d4f8ae548fb7 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 3 Jun 2026 09:11:15 +0530 Subject: [PATCH 06/19] feat: added failure annotation on build failures --- .github/slack/failure.json | 7 +++++++ .github/workflows/build-and-push.yml | 24 ++++++++++++++++++++++++ .github/workflows/release-lts.yml | 23 +++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/.github/slack/failure.json b/.github/slack/failure.json index a6e8d9a..9aa57df 100644 --- a/.github/slack/failure.json +++ b/.github/slack/failure.json @@ -36,6 +36,13 @@ "text": "⚠️ *The build failed during the CI/CD pipeline. Please review the logs and fix the issue before retrying.*" } }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*💥 Failure Details:*\n```${FAILURE_REASON}```" + } + }, { "type": "divider" }, diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index a04bdc5..2be97c7 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -329,6 +329,29 @@ jobs: }); } + - name: Fetch failure annotations + id: error-log + uses: actions/github-script@v6 + with: + script: | + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + }); + const failedJob = jobs.data.jobs.find(j => j.conclusion === 'failure'); + let errorMsg = 'No error details available'; + if (failedJob) { + const failedSteps = failedJob.steps + .filter(s => s.conclusion === 'failure') + .map(s => `• ${s.name}`) + .join('\n'); + errorMsg = `Failed job: ${failedJob.name}\n${failedSteps}`; + } + // Truncate to 500 chars for Slack block limits + if (errorMsg.length > 500) errorMsg = errorMsg.substring(0, 497) + '...'; + core.setOutput('message', errorMsg); + - name: Send Slack notification - Failure env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -340,6 +363,7 @@ jobs: GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} BUILD_TIMESTAMP: ${{ github.event.head_commit.timestamp }} + FAILURE_REASON: ${{ steps.error-log.outputs.message }} run: | PAYLOAD=$(envsubst < .github/slack/failure.json) echo "$PAYLOAD" | jq . > /dev/null diff --git a/.github/workflows/release-lts.yml b/.github/workflows/release-lts.yml index 2fe73a1..d814cce 100644 --- a/.github/workflows/release-lts.yml +++ b/.github/workflows/release-lts.yml @@ -328,6 +328,28 @@ jobs: }); } + - name: Fetch failure annotations + id: error-log + uses: actions/github-script@v6 + with: + script: | + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + }); + const failedJob = jobs.data.jobs.find(j => j.conclusion === 'failure'); + let errorMsg = 'No error details available'; + if (failedJob) { + const failedSteps = failedJob.steps + .filter(s => s.conclusion === 'failure') + .map(s => `• ${s.name}`) + .join('\n'); + errorMsg = `Failed job: ${failedJob.name}\n${failedSteps}`; + } + if (errorMsg.length > 500) errorMsg = errorMsg.substring(0, 497) + '...'; + core.setOutput('message', errorMsg); + - name: Send Slack notification - Failure env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -339,6 +361,7 @@ jobs: GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} BUILD_TIMESTAMP: ${{ needs.prepare-metadata.outputs.build-time }} + FAILURE_REASON: ${{ steps.error-log.outputs.message }} run: | PAYLOAD=$(envsubst < .github/slack/failure.json) echo "$PAYLOAD" | jq . > /dev/null From a2e106dbf0c4592e1e75fd0c5728f230968e3518 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 21:27:37 +0530 Subject: [PATCH 07/19] feat(auth): register AuthMiddleware on protected routes - Apply AuthMiddleware to /storage and /assets route groups - Run auth before presign rate limiter to reject anonymous traffic early - Keep info and health endpoints public - Add unit tests for reject paths and userID context injection --- internal/middleware/authorization_test.go | 95 +++++++++++++++++++++++ internal/router/router.go | 2 + 2 files changed, 97 insertions(+) create mode 100644 internal/middleware/authorization_test.go diff --git a/internal/middleware/authorization_test.go b/internal/middleware/authorization_test.go new file mode 100644 index 0000000..de6ae35 --- /dev/null +++ b/internal/middleware/authorization_test.go @@ -0,0 +1,95 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/rndmcodeguy20/mpiper/internal/config" + "github.com/rndmcodeguy20/mpiper/pkg/utils" + "go.uber.org/zap" +) + +// 32-byte AES-256 key for the test singleton. +const testEncryptionKey = "0123456789abcdef0123456789abcdef" + +func TestMain(m *testing.M) { + config.Init(config.EnvConfig{EncryptionKey: testEncryptionKey}) + m.Run() +} + +// newGate wraps a handler that records whether it ran with AuthMiddleware. +func newGate(t *testing.T) (http.Handler, *bool) { + t.Helper() + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + return AuthMiddleware(zap.NewNop())(next), &called +} + +func TestAuthMiddleware_RejectsUnauthenticated(t *testing.T) { + tests := []struct { + name string + header string + }{ + {"missing header", ""}, + {"non-bearer scheme", "Basic abc123"}, + {"bearer without token", "Bearer "}, + {"malformed token", "Bearer not-a-valid-token"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gate, called := newGate(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/assets/x/complete", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + rec := httptest.NewRecorder() + + gate.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if *called { + t.Error("next handler ran for unauthenticated request — gate leaked") + } + }) + } +} + +func TestAuthMiddleware_AllowsValidTokenAndPopulatesUserID(t *testing.T) { + const wantUserID = "user-42" + token, err := utils.GenerateToken(wantUserID, testEncryptionKey) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + var gotUserID string + var gotOK bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserID, gotOK = GetUserID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + gate := AuthMiddleware(zap.NewNop())(next) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/assets/x/complete", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + gate.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !gotOK { + t.Fatal("GetUserID returned ok=false — userID not injected into context") + } + if gotUserID != wantUserID { + t.Errorf("userID = %q, want %q", gotUserID, wantUserID) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 578fec0..486f54d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -144,10 +144,12 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB, m *metrics.Metrics) *chi.Mux { }) r.Route("/storage", func(r chi.Router) { + r.Use(appMiddleware.AuthMiddleware(logger)) r.With(presignRateLimiter()).Post("/presign", assetHandler.CreateAsset) }) r.Route("/assets", func(r chi.Router) { + r.Use(appMiddleware.AuthMiddleware(logger)) r.Get("/{assetID}/complete", assetHandler.MarkAssetUploaded) }) }) From 3babbf2ef7395e4dae0233d77c1ab3d85d9c24d9 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 21:49:27 +0530 Subject: [PATCH 08/19] fix(otel): default OTLP exporter to plaintext gRPC - Invert OTEL_TLS_INSECURE default so unset means plaintext, not TLS - Add parseTLSInsecure helper: only "false" opts into TLS - Stop silent telemetry loss against the bundled plaintext collector - Add unit tests for the env-var parsing --- internal/config/env.go | 8 +++++++- internal/config/env_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 internal/config/env_test.go diff --git a/internal/config/env.go b/internal/config/env.go index 2179c94..981812e 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -190,7 +190,7 @@ func GetEnvConfig(envFile string) (EnvConfig, error) { }, Otel: OtelConfig{ Endpoint: envOr("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"), - TLSInsecure: strings.ToLower(os.Getenv("OTEL_TLS_INSECURE")) == "true", + TLSInsecure: parseTLSInsecure(os.Getenv("OTEL_TLS_INSECURE")), DeploymentEnv: envOr("DEPLOYMENT_ENV", env), TraceSamplingRate: traceSamplingRate, ServiceName: envOr("SERVICE_NAME", "mpiper-api"), @@ -210,6 +210,12 @@ func GetEnvConfig(envFile string) (EnvConfig, error) { }, nil } +// parseTLSInsecure defaults to plaintext (true); TLS is opt-in via OTEL_TLS_INSECURE=false. +// The bundled collector speaks plaintext gRPC, so secure-by-default would silently drop all telemetry. +func parseTLSInsecure(raw string) bool { + return strings.ToLower(strings.TrimSpace(raw)) != "false" +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..cd67040 --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestParseTLSInsecure(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + {"unset defaults to plaintext", "", true}, + {"explicit false enables TLS", "false", false}, + {"uppercase FALSE enables TLS", "FALSE", false}, + {"padded false enables TLS", " false ", false}, + {"explicit true is plaintext", "true", true}, + {"garbage value is plaintext", "yes", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := parseTLSInsecure(tc.raw); got != tc.want { + t.Errorf("parseTLSInsecure(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} From de030b0daff25c9dd119c6b236cbba0f06d12d58 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 22:21:34 +0530 Subject: [PATCH 09/19] fix(otel): flush telemetry on shutdown with fresh context - Use a fresh 10s context for tracer and metrics shutdown - Stop passing the cancelled serverCtx, which aborted the flush - Pass serverCtx directly to init; drop redundant alias vars --- cmd/server/main.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 49fb411..c012e06 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -55,18 +55,20 @@ func main() { baseLogger.Sugar().Infof("Starting %s server on https://%s:%d in %s mode", "MPiper", cfg.Server.Host, cfg.Server.Port, cfg.Environment) - tracerCtx := serverCtx - shutdownTracer := metrics.InitTracer(tracerCtx, baseLogger) + shutdownTracer := metrics.InitTracer(serverCtx, baseLogger) defer func() { - if err := shutdownTracer(tracerCtx); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := shutdownTracer(ctx); err != nil { baseLogger.Sugar().Errorf("Failed to shut down tracer: %v", err) } }() - metricsCtx := serverCtx - m, shutdownMetrics := metrics.InitMetrics(metricsCtx, baseLogger) + m, shutdownMetrics := metrics.InitMetrics(serverCtx, baseLogger) defer func() { - if err := shutdownMetrics(metricsCtx); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := shutdownMetrics(ctx); err != nil { baseLogger.Sugar().Errorf("Failed to shut down metrics: %v", err) } }() From fa7f31c6a2114178dccdce4c933510e92f90ce74 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 22:36:23 +0530 Subject: [PATCH 10/19] chore(deploy): move compose to root and add worker health sentinel - Move docker-compose stack and observability overlay to project root - Remove obsolete deploy/docker/docker-compose.observability.yml - Write /tmp/worker_healthy sentinel for the worker container healthcheck - Update .env.example docs (compose hosts, storage providers, OTel TLS) - Add project CLAUDE.md --- .env.example | 31 ++-- CLAUDE.md | 97 +++++++++++++ ...ty.yml => docker-compose.observability.yml | 61 +++++--- docker-compose.yml | 133 ++++++++++++++++++ worker/consumer/consumer.py | 9 ++ 5 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 CLAUDE.md rename deploy/docker/docker-compose.observability.yml => docker-compose.observability.yml (81%) create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example index f745b30..5ce377b 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ HOST=0.0.0.0 PORT=5010 # ─── Database ───────────────────────────────────────────────────────────────── +# Docker Compose: DB_HOST=postgres +# Local dev (no compose): DB_HOST=localhost DB_HOST=localhost DB_PORT=5432 DB_USER=mpiper @@ -23,6 +25,8 @@ DB_MAX_RETRIES=5 DB_RETRY_DELAY=5 # ─── Redis ──────────────────────────────────────────────────────────────────── +# Docker Compose: REDIS_CONNECTION_STRING=redis://redis:6379/0 +# Local dev (no compose): REDIS_CONNECTION_STRING=redis://localhost:6379/0 REDIS_CONNECTION_STRING=redis://localhost:6379/0 # Connection pool (Python worker) @@ -38,20 +42,29 @@ REDIS_WRITE_TIMEOUT=10 # AES-256 key — must be exactly 32 characters ENCRYPTION_KEY=LKyGslR3InLES/EYQiJZcW06KFNMoevUd6kehjtrxPA= -# ─── Storage / GCS ──────────────────────────────────────────────────────────── +# ─── Storage ────────────────────────────────────────────────────────────────── +# Set BUCKET_PROVIDER to "gcs" or "s3" BUCKET_PROVIDER=gcs + +# Bucket name — required for both providers BUCKET_NAME=my-media-bucket -BUCKET_REGION=us-east-1 -BUCKET_ACCESS_KEY= -BUCKET_SECRET_KEY= -BUCKET_ENDPOINT_URL= -# Path to GCS service-account JSON key file -GCS_SA_PATH=/path/to/service-account.json -BUCKET_SA_PATH=/path/to/service-account.json + +# GCS (when BUCKET_PROVIDER=gcs) +# Path to the GCS service-account JSON key file. The Go API reads GCS_SA_PATH; +# the Python worker reads BUCKET_SA_PATH — keep both pointing at the same file. +GCS_SA_PATH=.secrets/service-account.json +BUCKET_SA_PATH=.secrets/service-account.json + +# S3 / S3-compatible (when BUCKET_PROVIDER=s3) — not yet implemented (sub-project 4) +# BUCKET_REGION=us-east-1 +# BUCKET_ACCESS_KEY= +# BUCKET_SECRET_KEY= +# BUCKET_ENDPOINT_URL= # optional — for MinIO or S3-compatible endpoints # ─── OpenTelemetry ──────────────────────────────────────────────────────────── OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 -# Set to "true" for local/dev collectors that don't use TLS +# Defaults to plaintext gRPC (true) when unset — matches the bundled collector. +# Set to "false" only if your collector endpoint terminates real TLS. OTEL_TLS_INSECURE=true DEPLOYMENT_ENV=development TRACE_SAMPLING_RATE=0.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6a74ad9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Dev server (hot-reload via air) +task run + +# Dev server (go run, no hot-reload) +task dev # ENV=development, LOG_LEVEL=DEBUG +task staging # ENV=staging, LOG_LEVEL=INFO +task prod # ENV=production, LOG_LEVEL=WARN + +# Run tests +task test # uses gotestsum +task test -- ./internal/... # run specific package +task test-coverage # generates coverage.html + +# Lint / format +task lint # golangci-lint +task fmt # go fmt + goimports + +# Build +task build # outputs build/mpiper.exe +task build-prod # ENV=production + +# Python worker (from project root) +poetry run python -m worker + +# Python tests +poetry run pytest worker/tests/ + +# Docker +task docker-build && task docker-run # API +task docker-build-worker && task docker-run-worker # Worker +``` + +Env files: `development` → `.env.local`, `staging` → `.env.staging`, `production` → `.env`. + +`ENV`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `REDIS_CONNECTION_STRING`, and `ENCRYPTION_KEY` (exactly 32 bytes) are required — the config will panic without them. + +## Architecture + +Two-service pipeline: **Go API server** + **Python media worker**, communicating via **Redis Streams** (`media:jobs` stream). Postgres is the durable source of truth; Redis is transport-only. + +### Go API server (`cmd/server`, `internal/`) + +Entry point: `cmd/server/main.go` + +1. `config.InitializeConfig` → `config.Init` — loads env file for the current `Env` build variable, stores singleton (`config.MustGet()` available everywhere after startup). +2. `pkg/logger.New` — builds a `*zap.Logger` with optional OTel log export. +3. `metrics.InitTracer` / `metrics.InitMetrics` — wires up OTel tracing + metrics exporters. +4. `database.NewPostgresDB` — `sqlx.DB` pool; if `AUTO_MIGRATE=true` runs embedded SQL migrations on startup. +5. `server.NewServer` → `server.Start` — Chi router with middleware stack: request-ID, logger, tracing, metrics, recovery, slow-request detector, CORS, auth. + +Layer layout inside `internal/`: +- `handler/` — HTTP handlers, read request → call service → write response via `pkg/utils/response` +- `service/` — business logic (`AssetService`); coordinates repo + queue + storage +- `repository/` — SQL queries via sqlx (`AssetRepository`) +- `router/` — Chi route registration; mounts handlers onto the router returned to `server/` +- `models/` — request/response structs (`UploadAssetRequest`, `UploadAssetResponse`); not DB models +- `queue/` — `RedisQueue.Enqueue` writes to the stream with OTel tracing + retry +- `metrics/` — OTel metric instruments (counters, histograms); `internal/metrics/metrics.go` defines all instruments, `otel.go` handles provider init/shutdown + +### Python worker (`worker/`) + +Entry: `worker/__main__.py` → `consumer/main.py` + +- `Consumer` (Redis Streams, consumer group) polls with `xreadgroup`, processes one message at a time. +- Message contains either `job_id` or `asset_id`. `job_id` is canonical; `asset_id` triggers an upsert into the `jobs` table first. +- `_handle_job` takes a `SELECT … FOR UPDATE` lock, marks the row `in_progress`, calls `process_asset_dispatch`, then marks `done` + acks the stream message. On failure it re-queues (up to `MAX_JOB_ATTEMPTS`). +- `_recover_stuck_pending` re-adds `pending/in_progress` jobs older than 2 min back to the stream (recovery path, called when no messages available). +- `worker/processing/processor.py` — `process_asset_dispatch` routes by asset type to `images.py` or `videos.py`. +- `worker/storage/` — `StorageX` ABC; `GCSStorage` is the concrete impl. +- `worker/utils/metrics.py` — Prometheus metrics via `prometheus_client`. + +### Shared concerns + +**Config singleton (Go):** `internal/config.MustGet()` — call only after `config.Init(cfg)` in `main`. Do not pass `*EnvConfig` via function params; use the singleton. + +**Logger (Go):** `pkg/logger` wraps zap. Request-scoped logger lives in `context`; retrieve with `applogger.FromContext(ctx)` or `middleware.LoggerFromContext(ctx)`. Base logger is constructed once in `main` and passed to subsystems. + +**Error types (Go):** `pkg/errors` has typed API errors (`NotFoundError`, `BadRequestError`, `UnauthorizedError`, `ConflictError`, `InternalServerErrorError`) each embedding `*ApiError` (carries `StatusCode`). Handler layer type-asserts on these to set HTTP status. Use `fmt.Errorf("op: %w", err)` for internal wrapping; use `errors.New*` constructors (e.g. `errors.NewNotFoundError`) at the service/handler boundary. + +**Storage (`pkg/utils/storagex`):** `StorageX` interface with `PutObject`, `GetObject`, `GeneratePresignedURL`, `PublicURL`, `DeleteObject`. Current impl: `GCSStorage`. S3/MinIO provider types exist in config but are not yet implemented. + +**OTel:** Full tracing + metrics on the API side. Go instruments are in `internal/metrics/metrics.go`. Collector config at `observability/otel-collector.yml`; Grafana/Loki/Tempo/Prometheus configs in `observability/`. Python side uses `prometheus_client` (not OTel). + +### Database schema + +- `assets` — core media record; `status` enum: `uploading → uploaded → processing → ready / failed` +- `variants.image` — deduplicated by `variant_hash` (content+params hash); immutable once written +- `jobs` — processing job per asset; `status` enum: `pending → in_progress → done / failed`; `attempts` tracked for retry cap + +Migrations are plain SQL in `db/migrations/`. The Go server can auto-run them at startup (`AUTO_MIGRATE=true`); the Python worker also runs them via `worker/consumer/migrations.py`. diff --git a/deploy/docker/docker-compose.observability.yml b/docker-compose.observability.yml similarity index 81% rename from deploy/docker/docker-compose.observability.yml rename to docker-compose.observability.yml index c04229b..65d4760 100644 --- a/deploy/docker/docker-compose.observability.yml +++ b/docker-compose.observability.yml @@ -1,10 +1,17 @@ # ============================================================================ -# Complete Observability Stack for MPiper +# MPiper — Observability overlay (opt-in) +# +# docker compose -f docker-compose.yml -f docker-compose.observability.yml up +# # - Grafana: Visualization and dashboards # - Tempo: Distributed tracing backend # - Prometheus: Metrics collection and storage # - Loki: Log aggregation # - OTEL Collector: Centralized telemetry collection +# +# `otel-collector` joins the core `mpiper_net` (external) so the api and worker +# can reach it at `otel-collector:4317`. The remaining observability services +# stay isolated on `mpiper_obs_net`. # ============================================================================ name: mpiper-observability-stack @@ -18,7 +25,7 @@ services: container_name: mpiper-tempo command: ["-config.file=/etc/tempo.yaml"] volumes: - - ../../observability/tempo.yml:/etc/tempo.yaml + - ./observability/tempo.yml:/etc/tempo.yaml - tempo-data:/var/tempo ports: - "3200:3200" # Tempo HTTP @@ -27,7 +34,7 @@ services: - "9411:9411" # Zipkin receiver - "14268:14268" # Jaeger ingest networks: - - mpiper-observability + - mpiper_obs_net restart: unless-stopped # ========================================================================== @@ -41,35 +48,35 @@ services: - GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin - GF_AUTH_DISABLE_LOGIN_FORM=false - + # Default admin credentials (CHANGE IN PRODUCTION!) - GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_PASSWORD=admin - + # Enable explore by default - GF_EXPLORE_ENABLED=true - + # Server settings - GF_SERVER_HTTP_PORT=3000 - GF_SERVER_DOMAIN=localhost - + # Feature toggles - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor,traceqlSearch - + # Log level - GF_LOG_LEVEL=info - + volumes: # Pre-configured data sources - - ../../observability/grafana/datasources:/etc/grafana/provisioning/datasources + - ./observability/grafana/datasources:/etc/grafana/provisioning/datasources # Pre-configured dashboards - - ../../observability/grafana/dashboards:/etc/grafana/provisioning/dashboards + - ./observability/grafana/dashboards:/etc/grafana/provisioning/dashboards # Persistent storage for user preferences - grafana-data:/var/lib/grafana ports: - "3000:3000" networks: - - mpiper-observability + - mpiper_obs_net depends_on: - tempo - prometheus @@ -96,12 +103,12 @@ services: - '--web.console.templates=/usr/share/prometheus/consoles' - '--web.enable-lifecycle' volumes: - - ../../observability/prometheus.yml:/etc/prometheus/prometheus.yml + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus ports: - "9090:9090" networks: - - mpiper-observability + - mpiper_obs_net restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] @@ -118,12 +125,12 @@ services: container_name: mpiper-loki command: -config.file=/etc/loki/loki.yaml volumes: - - ../../observability/loki.yml:/etc/loki/loki.yaml + - ./observability/loki.yml:/etc/loki/loki.yaml - loki-data:/loki ports: - "3100:3100" networks: - - mpiper-observability + - mpiper_obs_net restart: unless-stopped # ========================================================================== @@ -134,31 +141,33 @@ services: container_name: mpiper-promtail command: -config.file=/etc/promtail/promtail.yaml volumes: - - ../../observability/promtail.yml:/etc/promtail/promtail.yaml + - ./observability/promtail.yml:/etc/promtail/promtail.yaml - /var/log:/var/log:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock:ro networks: - - mpiper-observability + - mpiper_obs_net depends_on: - loki restart: unless-stopped # ========================================================================== # OpenTelemetry Collector - Centralized Telemetry Pipeline + # Bridges mpiper_net (reachable by api/worker at otel-collector:4317) + # and mpiper_obs_net (forwards to tempo/prometheus/loki). # ========================================================================== otel-collector: image: otel/opentelemetry-collector-contrib:latest container_name: mpiper-otel-collector command: ["--config=/etc/otel-collector.yaml"] env_file: - - ../../.env.local + - .env.local environment: DEPLOYMENT_ENV: local SERVICE_VERSION: dev K8S_CLUSTER_NAME: docker-compose volumes: - - ../../observability/otel-collector.yml:/etc/otel-collector.yaml + - ./observability/otel-collector.yml:/etc/otel-collector.yaml - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "4319:4317" # OTLP gRPC (host:4319 -> container:4317) @@ -168,7 +177,8 @@ services: - "13133:13133" # Health check - "55679:55679" # zPages extension networks: - - mpiper-observability + - mpiper_net + - mpiper_obs_net depends_on: - tempo - prometheus @@ -185,9 +195,14 @@ services: # NETWORKS # ============================================================================ networks: - mpiper-observability: + # Joined from docker-compose.yml — the core network the api/worker run on. + mpiper_net: + external: true + name: mpiper_net + # Internal observability network — isolated from core services. + mpiper_obs_net: driver: bridge - name: mpiper-observability + name: mpiper_obs_net # ============================================================================ # VOLUMES diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d0e3a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,133 @@ +# ============================================================================ +# MPiper — Core services +# +# docker compose up +# +# Core stack: api, worker, postgres, redis on the `mpiper_net` bridge network. +# Services address each other by service name (DB_HOST=postgres, etc.). +# +# Optional observability overlay (otel-collector + Grafana/Tempo/Prometheus/Loki): +# docker compose -f docker-compose.yml -f docker-compose.observability.yml up +# +# Copy .env.example -> .env.local and fill in required values before running. +# ============================================================================ + +name: mpiper + +services: + # ========================================================================== + # Go API server + # ========================================================================== + api: + build: + context: . + dockerfile: deploy/docker/mpiper.dockerfile + container_name: mpiper-api + env_file: + - .env.local + environment: + # Compose-internal addressing — services reach each other by name. + DB_HOST: postgres + REDIS_CONNECTION_STRING: redis://redis:6379/0 + # Always apply migrations on first run; override in a compose override file. + AUTO_MIGRATE: "true" + ports: + - "5010:5010" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - mpiper_net + restart: unless-stopped + healthcheck: + test: ["CMD", "/app/mpiper", "--health-check"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + # ========================================================================== + # Python media worker + # ========================================================================== + worker: + build: + context: . + dockerfile: deploy/docker/worker.dockerfile + container_name: mpiper-worker + env_file: + - .env.local + environment: + # Compose-internal addressing — services reach each other by name. + DB_HOST: postgres + REDIS_CONNECTION_STRING: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - mpiper_net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/worker_healthy || exit 1"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 20s + + # ========================================================================== + # Postgres — durable source of truth + # ========================================================================== + postgres: + image: postgres:16-alpine + container_name: mpiper-postgres + # Postgres init reads POSTGRES_* below. These mirror DB_USER/DB_PASSWORD/DB_NAME. + # Values interpolate from a `.env` file or the shell; the defaults match + # .env.example so plain `docker compose up` works out of the box. To override, + # set DB_USER / DB_PASSWORD / DB_NAME in `.env` (compose's default env file). + environment: + POSTGRES_USER: ${DB_USER:-mpiper} + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} + POSTGRES_DB: ${DB_NAME:-mpiper} + volumes: + - mpiper_postgres_data:/var/lib/postgresql/data + networks: + - mpiper_net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + # ========================================================================== + # Redis — transport-only (ephemeral, no volume by design) + # ========================================================================== + redis: + image: redis:7-alpine + container_name: mpiper-redis + networks: + - mpiper_net + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + +# ============================================================================ +# NETWORKS +# ============================================================================ +networks: + mpiper_net: + driver: bridge + name: mpiper_net + +# ============================================================================ +# VOLUMES +# ============================================================================ +volumes: + mpiper_postgres_data: # Postgres data — persists across restarts diff --git a/worker/consumer/consumer.py b/worker/consumer/consumer.py index 10a7288..0aecbb9 100644 --- a/worker/consumer/consumer.py +++ b/worker/consumer/consumer.py @@ -93,6 +93,15 @@ def __init__( except ResponseError as exc: logger.debug("consumer group exists or cannot be created: %s", exc) + # Write a health sentinel once the consumer group is initialised. The + # container healthcheck (test -f /tmp/worker_healthy) reads this file. + # Reaching this point means Redis is connected and the group exists. + try: + with open("/tmp/worker_healthy", "w") as fh: + fh.write("ok") + except OSError as exc: + logger.warning("could not write health sentinel: %s", exc) + def consume(self, consumer_name: str) -> bool: """Poll the stream and process a single message. From 0c90cda62abd6e89fd8ae14ec17c1bd21899a237 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 22:46:34 +0530 Subject: [PATCH 11/19] fix(worker): let consumer own asset state on failure - Stop the processor stamping assets.status=failed on every exception - Retryable failures no longer leave the asset stuck failed mid-retry - Consumer already marks failed only past the retry cap, ready on success - Add regression test asserting no failed-status write on processing error --- worker/processing/processor.py | 12 +++--- worker/tests/test_processor_dispatch.py | 54 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 worker/tests/test_processor_dispatch.py diff --git a/worker/processing/processor.py b/worker/processing/processor.py index 2b78e86..50c38c1 100644 --- a/worker/processing/processor.py +++ b/worker/processing/processor.py @@ -204,14 +204,12 @@ def process_asset_dispatch( raise ValueError(f"Unknown asset type: {typ}") except Exception as e: + # Do not touch assets.status here. The consumer (_handle_job) owns the + # asset state transition: it marks the asset failed only after the retry + # cap is hit, and ready on success. Writing 'failed' on every exception + # — including RetryableException — left the asset stuck failed across + # retries even though the job was still pending. See DEV-34. logger.error("Failed to process asset %s: %s", asset_id, e, exc_info=True) - with pg_pool.get_pg_conn() as conn: - cur = conn.cursor() - cur.execute( - "UPDATE assets SET status = %s, error_reason = %s WHERE asset_id = %s", - (AssetStatus.FAILED.value, str(e), asset_id) - ) - conn.commit() raise finally: if local_raw_file and os.path.exists(local_raw_file): diff --git a/worker/tests/test_processor_dispatch.py b/worker/tests/test_processor_dispatch.py new file mode 100644 index 0000000..76cec9a --- /dev/null +++ b/worker/tests/test_processor_dispatch.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import MagicMock, patch + +from worker.processing.processor import process_asset_dispatch, AssetStatus + + +class TestDispatchFailureDoesNotTouchAssetState(unittest.TestCase): + """DEV-34: the processor must NOT mark assets.status=failed on exception. + + The consumer (_handle_job) owns the asset state transition — it only marks + the asset failed after the retry cap. If the processor stamps 'failed' on + every exception, retryable failures leave the asset stuck failed mid-retry. + """ + + def _make_pg_pool(self, asset_row): + cursor = MagicMock() + cursor.fetchone.return_value = asset_row + conn = MagicMock() + conn.cursor.return_value = cursor + pg_pool = MagicMock() + pg_pool.get_pg_conn.return_value.__enter__.return_value = conn + return pg_pool, cursor + + @patch("worker.processing.processor.get_extension_for_mime", return_value="jpg") + @patch("worker.processing.processor.compute_file_hash", return_value="") + @patch("worker.processing.processor.process_image_file") + def test_processing_failure_leaves_asset_status_untouched( + self, mock_process_image, _mock_hash, _mock_ext + ): + mock_process_image.side_effect = RuntimeError("boom") + + # (asset_id, type, status, original_url, mime_type, content_hash) + asset_row = ("asset-1", "image", "uploaded", "gs://raw/asset-1", "image/jpeg", None) + pg_pool, cursor = self._make_pg_pool(asset_row) + storage = MagicMock() + cfg = MagicMock() + cfg.temp_dir = "/tmp" + + # The exception must propagate so the consumer can decide retry vs. fail. + with self.assertRaises(RuntimeError): + process_asset_dispatch("asset-1", pg_pool, storage, cfg) + + # No executed statement may set the asset to 'failed'. + for call in cursor.execute.call_args_list: + params = call.args[1] if len(call.args) > 1 else () + self.assertNotIn( + AssetStatus.FAILED.value, + tuple(params), + msg=f"processor wrote a failed-status update: {call.args}", + ) + + +if __name__ == "__main__": + unittest.main() From 4e9639226508163f62752c7ec329c9b5a4f747fa Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 22:51:12 +0530 Subject: [PATCH 12/19] fix(worker): run stuck-job recovery on a fixed cadence - Time-gate recovery via _maybe_recover and call it every consume() - Recover regardless of load instead of only on the idle path - Stop in_progress jobs from a crashed worker getting stuck under load - Sweep leftovers at startup (_last_recovery starts at 0) - Add tests for the load-path firing and the interval gate --- worker/consumer/consumer.py | 26 +++++++++++- worker/tests/test_consumer_recovery.py | 56 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 worker/tests/test_consumer_recovery.py diff --git a/worker/consumer/consumer.py b/worker/consumer/consumer.py index 0aecbb9..a23eb30 100644 --- a/worker/consumer/consumer.py +++ b/worker/consumer/consumer.py @@ -27,6 +27,7 @@ from __future__ import annotations +import time from typing import Dict import redis @@ -84,6 +85,13 @@ def __init__( self.storage = storage self.cfg = cfg + # Periodic recovery state. _last_recovery = 0 makes recovery run on the + # first consume() so leftovers from a prior crash are swept at startup. + # The interval matches the 2-minute staleness threshold in the recovery + # query. See DEV-35. + self._last_recovery = 0.0 + self._recovery_interval = 120.0 + # Ensure the consumer group exists. If it already exists Redis raises an # error; ignore that specific error. try: @@ -121,6 +129,11 @@ def consume(self, consumer_name: str) -> bool: True if a message was consumed (even if processing failed), False if no messages were available. """ + # Recover stuck jobs on a fixed cadence, independent of load. Doing this + # only on the idle path meant recovery never ran under sustained load — + # exactly when crashed-mid-job rows are most likely. See DEV-35. + self._maybe_recover() + # Read one message for this consumer (blocking short period) resp = self.redis.xreadgroup( groupname=self.cfg.consumer_group, @@ -131,8 +144,6 @@ def consume(self, consumer_name: str) -> bool: ) if not resp: - # No messages available; attempt recovery of stuck jobs and return. - self._recover_stuck_pending() return False # Response format: [(stream_name, [(msg_id, {field: value}), ...])] @@ -314,6 +325,17 @@ def _handle_asset_message(self, asset_id: str, msg_id: str) -> None: # Delegate to _handle_job using the job id we now have. self._handle_job(job_id, msg_id) + def _maybe_recover(self) -> None: + """Run stuck-job recovery if the recovery interval has elapsed. + + Time-gated so recovery fires on a fixed cadence regardless of whether + the consumer is busy or idle. + """ + now = time.time() + if now - self._last_recovery >= self._recovery_interval: + self._last_recovery = now + self._recover_stuck_pending() + def _recover_stuck_pending(self) -> None: """Requeue stale pending/in_progress jobs back onto the stream. diff --git a/worker/tests/test_consumer_recovery.py b/worker/tests/test_consumer_recovery.py new file mode 100644 index 0000000..d76cb40 --- /dev/null +++ b/worker/tests/test_consumer_recovery.py @@ -0,0 +1,56 @@ +import time +import unittest +from unittest.mock import MagicMock, patch + +from worker.consumer.consumer import Consumer + + +def _make_consumer(): + """Build a Consumer with redis/pg mocked out (no real connections).""" + cfg = MagicMock() + cfg.stream_name = "media:jobs" + cfg.consumer_group = "media-workers" + with patch("worker.consumer.consumer.redis.Redis.from_url") as from_url: + client = MagicMock() + from_url.return_value = client + consumer = Consumer( + pg_pool=MagicMock(), redis_url="redis://x", storage=MagicMock(), cfg=cfg + ) + return consumer, client + + +class TestPeriodicRecovery(unittest.TestCase): + """DEV-35: recovery must fire on a cadence even under continuous load.""" + + def test_recovery_fires_under_load_when_interval_elapsed(self): + consumer, client = _make_consumer() + # A message is available (the loaded / busy path). + client.xreadgroup.return_value = [("media:jobs", [("1-0", {"job_id": "42"})])] + + consumer._recover_stuck_pending = MagicMock() + consumer._handle_job = MagicMock() + consumer._recovery_interval = 0.0 # gate always open + consumer._last_recovery = 0.0 + + result = consumer.consume("worker-1") + + self.assertTrue(result) # work was performed + consumer._handle_job.assert_called_once_with("42", "1-0") + consumer._recover_stuck_pending.assert_called_once() # fired despite load + + def test_recovery_does_not_fire_before_interval_elapses(self): + consumer, client = _make_consumer() + client.xreadgroup.return_value = [("media:jobs", [("1-0", {"job_id": "42"})])] + + consumer._recover_stuck_pending = MagicMock() + consumer._handle_job = MagicMock() + consumer._recovery_interval = 9999.0 + consumer._last_recovery = time.time() # just recovered + + consumer.consume("worker-1") + + consumer._recover_stuck_pending.assert_not_called() # gate holds + + +if __name__ == "__main__": + unittest.main() From c7b3c70ac355ced461a02e11c9a483959333ff6f Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 22:53:18 +0530 Subject: [PATCH 13/19] ci: drop `file` check from docker build verification - alpine builder has no `file` util, so the verify step exited 127 - ls -lh already confirms the binary exists; that is enough --- deploy/docker/mpiper.dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/docker/mpiper.dockerfile b/deploy/docker/mpiper.dockerfile index 06ce606..ec8eae1 100644 --- a/deploy/docker/mpiper.dockerfile +++ b/deploy/docker/mpiper.dockerfile @@ -34,8 +34,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ -a -installsuffix cgo \ -o mpiper ./cmd/server/main.go -# Verify the binary was created -RUN ls -lh /build/mpiper && file /build/mpiper +# Verify the binary was created (the alpine builder has no `file` util) +RUN ls -lh /build/mpiper # Stage 2: Runtime - Minimal distroless image FROM gcr.io/distroless/static-debian12:nonroot From 269a684d07d4c34c9d8c5678ed3471dd3a39149a Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 23:15:07 +0530 Subject: [PATCH 14/19] =?UTF-8?q?fix(api):=20hardening=20batch=20=E2=80=94?= =?UTF-8?q?=20recovery=20order,=20request=20IDs,=20MIME=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move RecoveryMiddleware outermost so panics in inner middleware are caught (DEV-36) - Replace time-based request-ID chars with math/rand/v2 (unbiased, no collisions) (DEV-37) - Centralise supported MIME types in repository.SupportedMIMETypes; handler gates against it (DEV-38) - Add tests for request-ID generation and the MIME gate --- internal/handler/asset_handler.go | 11 ++------ internal/middleware/logging.go | 5 +++- internal/middleware/logging_test.go | 42 +++++++++++++++++++++++++++++ internal/repository/asset_repo.go | 17 ++++++++++++ internal/repository/mime_test.go | 30 +++++++++++++++++++++ internal/router/router.go | 6 ++++- 6 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 internal/middleware/logging_test.go create mode 100644 internal/repository/mime_test.go diff --git a/internal/handler/asset_handler.go b/internal/handler/asset_handler.go index 1473230..8c92521 100644 --- a/internal/handler/asset_handler.go +++ b/internal/handler/asset_handler.go @@ -9,6 +9,7 @@ import ( "github.com/rndmcodeguy20/mpiper/internal/config" "github.com/rndmcodeguy20/mpiper/internal/metrics" "github.com/rndmcodeguy20/mpiper/internal/models" + "github.com/rndmcodeguy20/mpiper/internal/repository" "github.com/rndmcodeguy20/mpiper/internal/service" "github.com/rndmcodeguy20/mpiper/pkg/utils" "go.opentelemetry.io/otel" @@ -17,14 +18,6 @@ import ( "go.uber.org/zap" ) -var allowedMIMETypes = map[string]bool{ - "image/jpeg": true, - "image/png": true, - "image/webp": true, - "video/mp4": true, - "video/quicktime": true, -} - func maxAssetSize() int64 { return config.MustGet().MaxAssetSizeBytes } @@ -85,7 +78,7 @@ func (h *AssetHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { return } - if !allowedMIMETypes[req.ContentType] { + if !repository.IsSupportedMIMEType(req.ContentType) { span.SetStatus(codes.Error, "unsupported content type") utils.RespondJSON(w, map[string]string{"status": "error", "message": "unsupported content type"}, http.StatusBadRequest) return diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go index a8cd7bc..c046ab2 100644 --- a/internal/middleware/logging.go +++ b/internal/middleware/logging.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "math/rand/v2" "net/http" "time" @@ -82,7 +83,9 @@ func randomString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, length) for i := range b { - b[i] = charset[time.Now().UnixNano()%int64(len(charset))] + // math/rand/v2 is concurrency-safe and unbiased; a log-correlation ID + // needs neither crypto strength nor per-call seeding. + b[i] = charset[rand.IntN(len(charset))] } return string(b) } diff --git a/internal/middleware/logging_test.go b/internal/middleware/logging_test.go new file mode 100644 index 0000000..d1528fa --- /dev/null +++ b/internal/middleware/logging_test.go @@ -0,0 +1,42 @@ +package middleware + +import "testing" + +func TestRandomString(t *testing.T) { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + inCharset := func(s string) bool { + for _, c := range s { + found := false + for _, allowed := range charset { + if c == allowed { + found = true + break + } + } + if !found { + return false + } + } + return true + } + + // Length + charset. + s := randomString(8) + if len(s) != 8 { + t.Fatalf("len = %d, want 8", len(s)) + } + if !inCharset(s) { + t.Errorf("%q contains chars outside charset", s) + } + + // Many rapid calls must not collide (the old time-based impl produced + // identical IDs within a nanosecond window). + seen := make(map[string]struct{}, 1000) + for i := 0; i < 1000; i++ { + id := randomString(8) + if _, dup := seen[id]; dup { + t.Fatalf("duplicate id %q on iteration %d", id, i) + } + seen[id] = struct{}{} + } +} diff --git a/internal/repository/asset_repo.go b/internal/repository/asset_repo.go index c81fd08..09a1234 100644 --- a/internal/repository/asset_repo.go +++ b/internal/repository/asset_repo.go @@ -54,6 +54,23 @@ func ToAssetType(fileType string) AssetType { } } +// SupportedMIMETypes is the single source of truth for which content types the +// pipeline accepts for upload and processing, mapped to their asset category. +// The handler gates uploads against this set; do not maintain a second list. +var SupportedMIMETypes = map[string]AssetType{ + "image/jpeg": ImageAsset, + "image/png": ImageAsset, + "image/webp": ImageAsset, + "video/mp4": VideoAsset, + "video/quicktime": VideoAsset, +} + +// IsSupportedMIMEType reports whether the pipeline accepts the given content type. +func IsSupportedMIMEType(mimeType string) bool { + _, ok := SupportedMIMETypes[mimeType] + return ok +} + func ToAssetTypeFromMimeType(mimeType string) AssetType { if len(mimeType) < 5 { return OtherAsset diff --git a/internal/repository/mime_test.go b/internal/repository/mime_test.go new file mode 100644 index 0000000..ba3f138 --- /dev/null +++ b/internal/repository/mime_test.go @@ -0,0 +1,30 @@ +package repository + +import "testing" + +func TestSupportedMIMETypes(t *testing.T) { + supported := map[string]AssetType{ + "image/jpeg": ImageAsset, + "image/png": ImageAsset, + "image/webp": ImageAsset, + "video/mp4": VideoAsset, + "video/quicktime": VideoAsset, + } + + for mime, want := range supported { + if !IsSupportedMIMEType(mime) { + t.Errorf("IsSupportedMIMEType(%q) = false, want true", mime) + } + if got := SupportedMIMETypes[mime]; got != want { + t.Errorf("SupportedMIMETypes[%q] = %v, want %v", mime, got, want) + } + } + + // The gate must reject types the pipeline cannot process, even ones the + // broad classifier would still bucket (e.g. gif, pdf). + for _, mime := range []string{"image/gif", "application/pdf", "audio/mpeg", "text/plain", ""} { + if IsSupportedMIMEType(mime) { + t.Errorf("IsSupportedMIMEType(%q) = true, want false", mime) + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 486f54d..d8a90b0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -93,6 +93,11 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB, m *metrics.Metrics) *chi.Mux { // Middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) + // Recovery must be the outermost app-level middleware so panics in any + // inner middleware (logger, cors, tracing, …) are caught and turned into a + // 500 rather than crashing the process. It takes the base logger directly, + // so it does not depend on LoggerMiddleware running first. + r.Use(appMiddleware.RecoveryMiddleware(logger)) r.Use(cors.Handler(cors.Options{ AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, @@ -105,7 +110,6 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB, m *metrics.Metrics) *chi.Mux { r.Use(middleware.Timeout(MiddlewareTimeout)) r.Use(appMiddleware.TracingMiddleware) r.Use(appMiddleware.MetricsMiddleware(m)) - r.Use(appMiddleware.RecoveryMiddleware(logger)) r.Use(middleware.Compress(5)) r.Use(appMiddleware.SlowRequestMiddleware(logger, 2*time.Second)) From a70580e10fa4d0a7d3a08a04539a1fb98ba02a6f Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 23:17:42 +0530 Subject: [PATCH 15/19] fix(worker): fail fast on non-retryable exceptions - Only RetryableException re-queues; other errors fail the job/asset immediately - Stop burning the full retry budget on permanent failures (bad type, corrupt file) - Add tests for retryable requeue vs non-retryable fail-fast --- worker/consumer/consumer.py | 9 ++++- worker/tests/test_consumer_retry.py | 62 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 worker/tests/test_consumer_retry.py diff --git a/worker/consumer/consumer.py b/worker/consumer/consumer.py index a23eb30..71993cf 100644 --- a/worker/consumer/consumer.py +++ b/worker/consumer/consumer.py @@ -35,7 +35,7 @@ from worker.consumer.config import WorkerConfig from worker.consumer.db import PgPool -from worker.processing.processor import process_asset_dispatch +from worker.processing.processor import RetryableException, process_asset_dispatch from worker.storage.base import StorageX from worker.utils.logger import get_logger @@ -236,7 +236,12 @@ def _handle_job(self, job_id: int, msg_id: str) -> None: row = cur.fetchone() attempts_now = row[0] if row else 0 - if attempts_now >= self.cfg.redis.max_retries: + # Only RetryableException is worth retrying. Any other exception + # is permanent (bad asset type, corrupt file, decode failure) — + # fail it immediately instead of burning the whole retry budget. + retryable = isinstance(exc, RetryableException) + + if not retryable or attempts_now >= self.cfg.redis.max_retries: cur.execute( "UPDATE jobs SET status = 'failed', last_error = %s, updated_at = now() WHERE job_id = %s", (str(exc), str(job_id)), diff --git a/worker/tests/test_consumer_retry.py b/worker/tests/test_consumer_retry.py new file mode 100644 index 0000000..fcc281c --- /dev/null +++ b/worker/tests/test_consumer_retry.py @@ -0,0 +1,62 @@ +import unittest +from unittest.mock import MagicMock, patch + +from worker.consumer.consumer import Consumer +from worker.processing.processor import RetryableException + + +def _make_consumer(max_retries=3): + cfg = MagicMock() + cfg.stream_name = "media:jobs" + cfg.consumer_group = "media-workers" + cfg.redis.max_retries = max_retries + with patch("worker.consumer.consumer.redis.Redis.from_url") as from_url: + from_url.return_value = MagicMock() + consumer = Consumer( + pg_pool=MagicMock(), redis_url="redis://x", storage=MagicMock(), cfg=cfg + ) + # SELECT job row (FOR UPDATE), then SELECT attempts in the except block. + cursor = MagicMock() + cursor.fetchone.side_effect = [ + (42, "asset-1", "pending", 0), # job row: jid, asset_id, status, attempts + (0,), # attempts_now (below cap) + ] + conn = MagicMock() + conn.cursor.return_value = cursor + consumer.pg.get_pg_conn.return_value.__enter__.return_value = conn + return consumer, cursor + + +def _executed_sql(cursor): + return " | ".join(c.args[0] for c in cursor.execute.call_args_list if c.args) + + +class TestRetryClassification(unittest.TestCase): + """DEV-52: only RetryableException retries; other errors fail fast.""" + + @patch("worker.consumer.consumer.process_asset_dispatch") + def test_retryable_exception_requeues_below_cap(self, mock_dispatch): + mock_dispatch.side_effect = RetryableException("not ready yet") + consumer, cursor = _make_consumer() + + consumer._handle_job(42, "1-0") + + sql = _executed_sql(cursor) + self.assertIn("UPDATE jobs SET status = 'pending'", sql) + self.assertNotIn("UPDATE assets SET status = 'failed'", sql) + + @patch("worker.consumer.consumer.process_asset_dispatch") + def test_non_retryable_exception_fails_immediately(self, mock_dispatch): + mock_dispatch.side_effect = ValueError("Unknown asset type") + consumer, cursor = _make_consumer() + + consumer._handle_job(42, "1-0") + + sql = _executed_sql(cursor) + # Fails now despite attempts (0) being below the cap. + self.assertIn("UPDATE assets SET status = 'failed'", sql) + self.assertNotIn("UPDATE jobs SET status = 'pending'", sql) + + +if __name__ == "__main__": + unittest.main() From 927d73b3a263fd5682fb8f402203766a9d850676 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 23:28:31 +0530 Subject: [PATCH 16/19] feat(db): add webhook_registrations and webhook_deliveries tables - New migration 000002 for the webhook system (DEV-44) - webhook_deliveries is the outbox: status, attempts, next_attempt_at - Partial index on next_attempt_at WHERE status='pending' for the poll loop - ON DELETE CASCADE from deliveries to their registration - uuid_generate_v4 + IF NOT EXISTS to match existing schema conventions --- .../migrations/000002_webhooks.down.sql | 2 ++ .../migrations/000002_webhooks.up.sql | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 internal/database/migrations/000002_webhooks.down.sql create mode 100644 internal/database/migrations/000002_webhooks.up.sql diff --git a/internal/database/migrations/000002_webhooks.down.sql b/internal/database/migrations/000002_webhooks.down.sql new file mode 100644 index 0000000..8d6f7b6 --- /dev/null +++ b/internal/database/migrations/000002_webhooks.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS webhook_registrations; diff --git a/internal/database/migrations/000002_webhooks.up.sql b/internal/database/migrations/000002_webhooks.up.sql new file mode 100644 index 0000000..5f7ff72 --- /dev/null +++ b/internal/database/migrations/000002_webhooks.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS webhook_registrations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + url TEXT NOT NULL, + secret TEXT NOT NULL, -- encrypted at rest (ENCRYPTION_KEY) + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + registration_id UUID NOT NULL REFERENCES webhook_registrations (id) ON DELETE CASCADE, + event TEXT NOT NULL, -- 'job.done' | 'job.failed' + asset_id UUID NOT NULL, + job_id BIGINT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | failed + attempts INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + delivered_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS webhook_deliveries_pending_idx + ON webhook_deliveries (next_attempt_at) + WHERE status = 'pending'; From d4a97b859a8c71252a4969b9ec0c30114ae1258a Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 16 Jun 2026 23:52:42 +0530 Subject: [PATCH 17/19] feat(storage): add S3/MinIO StorageX provider and config-driven selection - Implement s3Storage (aws-sdk-go-v2) for AWS S3 and MinIO (DEV-48) - Add storagex.New factory; select provider from BUCKET_PROVIDER config - Neutralise GetObjectAttrs to a provider-agnostic storagex.ObjectAttrs - Add S3Config + BUCKET_NAME/S3_* env wiring; drop hardcoded "mpiper" bucket - MinIO support via S3_ENDPOINT_URL + path-style addressing - Add MinIO round-trip integration test (skips without S3_TEST_ENDPOINT) - Ignore local e2e artifacts (.env.local, .secrets, compose override) --- .gitignore | 6 +- go.mod | 23 ++- go.sum | 39 ++++- internal/config/env.go | 18 +++ internal/router/router.go | 3 +- internal/service/asset.go | 38 +++-- pkg/utils/storagex/config.go | 2 + pkg/utils/storagex/factory.go | 27 ++++ pkg/utils/storagex/gcs.go | 8 +- pkg/utils/storagex/s3.go | 273 +++++++++++++++++++++++++++++++++ pkg/utils/storagex/s3_test.go | 109 +++++++++++++ pkg/utils/storagex/storagex.go | 13 +- 12 files changed, 532 insertions(+), 27 deletions(-) create mode 100644 pkg/utils/storagex/factory.go create mode 100644 pkg/utils/storagex/s3.go create mode 100644 pkg/utils/storagex/s3_test.go diff --git a/.gitignore b/.gitignore index 674cf51..aded70f 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,8 @@ API_DOCUMENTATION.md .kimchi/ -.codegraph/ \ No newline at end of file +.codegraph/ +# local e2e verification (do not commit) +.env.local +.secrets/ +docker-compose.override.yml diff --git a/go.mod b/go.mod index fc97cc8..97919fb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,10 @@ go 1.25.0 require ( cloud.google.com/go/storage v1.58.0 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.25 + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 + github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/google/uuid v1.6.0 @@ -24,6 +28,23 @@ require ( google.golang.org/grpc v1.77.0 ) +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect +) + require ( cel.dev/expr v0.24.0 // indirect cloud.google.com/go v0.123.0 // indirect @@ -66,7 +87,7 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/time v0.14.0 google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/go.sum b/go.sum index 95d8d55..b24f082 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,42 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -145,7 +181,6 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= @@ -160,14 +195,12 @@ go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJ go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/log/logtest v0.20.0 h1:+tsZVE15N+RWyN9lUzsRyw7hMZXNMepGu105Eim82/k= go.opentelemetry.io/otel/log/logtest v0.20.0/go.mod h1:zS9Ryx9RrEAG2tgapMBSvacwhVSSOGSaSiWWgW3NPlQ= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= diff --git a/internal/config/env.go b/internal/config/env.go index 981812e..a000571 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -61,9 +61,19 @@ type GCSConfig struct { SAPath string } +type S3Config struct { + Bucket string + Region string + AccessKeyID string + SecretAccessKey string + EndpointURL string // optional — set for MinIO / S3-compatible stores +} + type StorageConfig struct { Provider string + Bucket string GCS GCSConfig + S3 S3Config } type EnvConfig struct { @@ -198,9 +208,17 @@ func GetEnvConfig(envFile string) (EnvConfig, error) { }, Storage: StorageConfig{ Provider: envOr("BUCKET_PROVIDER", "gcs"), + Bucket: envOr("BUCKET_NAME", "mpiper"), GCS: GCSConfig{ SAPath: os.Getenv("GCS_SA_PATH"), }, + S3: S3Config{ + Bucket: envOr("S3_BUCKET_NAME", envOr("BUCKET_NAME", "mpiper")), + Region: os.Getenv("S3_REGION"), + AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + EndpointURL: os.Getenv("S3_ENDPOINT_URL"), + }, }, CORSAllowedOrigins: corsOrigins, LogLevel: envOr("LOG_LEVEL", "INFO"), diff --git a/internal/router/router.go b/internal/router/router.go index d8a90b0..64bb72d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -19,7 +19,6 @@ import ( "github.com/rndmcodeguy20/mpiper/internal/service" applogger "github.com/rndmcodeguy20/mpiper/pkg/logger" "github.com/rndmcodeguy20/mpiper/pkg/utils" - "github.com/rndmcodeguy20/mpiper/pkg/utils/storagex" "golang.org/x/time/rate" ) @@ -114,7 +113,7 @@ func NewRouter(cfg config.EnvConfig, db *sqlx.DB, m *metrics.Metrics) *chi.Mux { r.Use(appMiddleware.SlowRequestMiddleware(logger, 2*time.Second)) assetRepo := repository.NewAssetRepository(db, logger, m) - assetSvc := service.NewAssetService(&cfg.Redis, storagex.GCPProvider, assetRepo, logger, m) + assetSvc := service.NewAssetService(&cfg.Redis, assetRepo, logger, m) assetHandler := handler.NewAssetHandler(assetSvc, logger, m) // Routes diff --git a/internal/service/asset.go b/internal/service/asset.go index b5a5d8a..8e1d48a 100644 --- a/internal/service/asset.go +++ b/internal/service/asset.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -30,25 +31,31 @@ type assetService struct { assetRepo repository.AssetRepository logger *zap.Logger storageClient storagex.StorageX + bucket string queue *queue.RedisQueue m *metrics.Metrics } -func NewAssetService(redisCfg *config.RedisConfig, provider storagex.Provider, assetRepo repository.AssetRepository, logger *zap.Logger, m *metrics.Metrics) AssetService { - var storageClient storagex.StorageX - var err error +func NewAssetService(redisCfg *config.RedisConfig, assetRepo repository.AssetRepository, logger *zap.Logger, m *metrics.Metrics) AssetService { ctx := context.Background() - switch provider { - case storagex.GCPProvider: - saPath := config.MustGet().Storage.GCS.SAPath - if saPath == "" { - logger.Sugar().Fatalf("GCS_SA_PATH is not set") - } - storageClient, err = storagex.NewGCSStorageFromServiceAccountJSON(ctx, saPath, m, logger) - default: - logger.Sugar().Fatalf("Unsupported storage provider: %v", provider) + storeCfg := config.MustGet().Storage + + // Effective bucket: S3 may override via S3_BUCKET_NAME, otherwise BUCKET_NAME. + bucket := storeCfg.Bucket + switch storagex.Provider(strings.ToLower(storeCfg.Provider)) { + case storagex.S3Provider, storagex.MinIOProvider: + bucket = storeCfg.S3.Bucket } + storageClient, err := storagex.New(ctx, storagex.Config{ + Provider: storagex.Provider(storeCfg.Provider), + Bucket: bucket, + Region: storeCfg.S3.Region, + Endpoint: storeCfg.S3.EndpointURL, + AccessKeyID: storeCfg.S3.AccessKeyID, + SecretAccessKey: storeCfg.S3.SecretAccessKey, + GCPServiceAccount: storeCfg.GCS.SAPath, + }, m, logger) if err != nil { logger.Sugar().Fatalf("Failed to initialize storage client: %v", err) } @@ -67,6 +74,7 @@ func NewAssetService(redisCfg *config.RedisConfig, provider storagex.Provider, a assetRepo: assetRepo, logger: logger, storageClient: storageClient, + bucket: bucket, queue: rq, m: m, } @@ -94,7 +102,7 @@ func (s *assetService) CreateAsset(ctx context.Context, request models.UploadAss // GeneratePresignedURL creates a temporary signed URL for uploading an object to the storage bucket. // It generates a PUT presigned URL valid for 5 minutes that allows clients to upload files // with the specified content type to the "mpiper" bucket at the given object key. - signedUrl, err := s.storageClient.GeneratePresignedURL(spanStorageCtx, "mpiper", objectKey, &storagex.PresignedURLOptions{ + signedUrl, err := s.storageClient.GeneratePresignedURL(spanStorageCtx, s.bucket, objectKey, &storagex.PresignedURLOptions{ Method: "PUT", ContentType: request.ContentType, ExpiresInSeconds: 60 * 5, // 5 minutes @@ -112,7 +120,7 @@ func (s *assetService) CreateAsset(ctx context.Context, request models.UploadAss spanStorageCtx, spanStorage = tracer.Start(ctx, "StorageClient.PublicURL") spanStorage.SetAttributes(attribute.String("object_key", objectKey)) - publicUrl, err := s.storageClient.PublicURL(spanStorageCtx, "mpiper", objectKey) + publicUrl, err := s.storageClient.PublicURL(spanStorageCtx, s.bucket, objectKey) spanStorage.End() if err != nil { @@ -181,7 +189,7 @@ func (s *assetService) MarkAssetUploaded(ctx context.Context, assetID uuid.UUID) ctxStorage, spanStorage := tracer.Start(ctx, "StorageClient.GetObjectAttrs") spanStorage.SetAttributes(attribute.String("object_key", objectKey)) - _, err := s.storageClient.GetObjectAttrs(ctxStorage, "mpiper", objectKey) + _, err := s.storageClient.GetObjectAttrs(ctxStorage, s.bucket, objectKey) spanStorage.End() if err != nil { diff --git a/pkg/utils/storagex/config.go b/pkg/utils/storagex/config.go index 0e878b9..78c4ccd 100644 --- a/pkg/utils/storagex/config.go +++ b/pkg/utils/storagex/config.go @@ -4,6 +4,8 @@ type Provider string const ( GCPProvider Provider = "gcp" + GCSProvider Provider = "gcs" + S3Provider Provider = "s3" MinIOProvider Provider = "minio" ) diff --git a/pkg/utils/storagex/factory.go b/pkg/utils/storagex/factory.go new file mode 100644 index 0000000..5319b15 --- /dev/null +++ b/pkg/utils/storagex/factory.go @@ -0,0 +1,27 @@ +package storagex + +import ( + "context" + "fmt" + "strings" + + "github.com/rndmcodeguy20/mpiper/internal/metrics" + "go.uber.org/zap" +) + +// New builds a StorageX for the configured provider. "gcs"/"gcp" use the GCS +// service-account client; "s3"/"minio" use the S3 client (MinIO when an +// endpoint is set). +func New(ctx context.Context, cfg Config, m *metrics.Metrics, logger *zap.Logger) (StorageX, error) { + switch Provider(strings.ToLower(string(cfg.Provider))) { + case GCSProvider, GCPProvider: + if cfg.GCPServiceAccount == "" { + return nil, fmt.Errorf("GCS service account path (GCS_SA_PATH) is not set") + } + return NewGCSStorageFromServiceAccountJSON(ctx, cfg.GCPServiceAccount, m, logger) + case S3Provider, MinIOProvider: + return NewS3Storage(ctx, cfg, m, logger) + default: + return nil, fmt.Errorf("unknown storage provider: %q", cfg.Provider) + } +} diff --git a/pkg/utils/storagex/gcs.go b/pkg/utils/storagex/gcs.go index 9199c6c..cae66bd 100644 --- a/pkg/utils/storagex/gcs.go +++ b/pkg/utils/storagex/gcs.go @@ -148,7 +148,7 @@ func (g *gcsStorage) GetObject(ctx context.Context, bucket, key string) (io.Read return rc, nil } -func (g *gcsStorage) GetObjectAttrs(ctx context.Context, bucket, key string) (*storage.ObjectAttrs, error) { +func (g *gcsStorage) GetObjectAttrs(ctx context.Context, bucket, key string) (*ObjectAttrs, error) { tracer := otel.Tracer("mpiper-api") ctx, span := tracer.Start(ctx, "GCS.GetObjectAttrs") defer span.End() @@ -171,7 +171,11 @@ func (g *gcsStorage) GetObjectAttrs(ctx context.Context, bucket, key string) (*s attribute.String("object.content_type", attrs.ContentType), ) span.SetStatus(codes.Ok, "Object attributes retrieved") - return attrs, nil + return &ObjectAttrs{ + Size: attrs.Size, + ContentType: attrs.ContentType, + ETag: attrs.Etag, + }, nil } func (g *gcsStorage) Close() error { diff --git a/pkg/utils/storagex/s3.go b/pkg/utils/storagex/s3.go new file mode 100644 index 0000000..a999ab5 --- /dev/null +++ b/pkg/utils/storagex/s3.go @@ -0,0 +1,273 @@ +package storagex + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/rndmcodeguy20/mpiper/internal/metrics" + "github.com/rndmcodeguy20/mpiper/pkg/errors" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" +) + +type s3Storage struct { + client *s3.Client + presign *s3.PresignClient + region string + endpoint string // non-empty for MinIO / S3-compatible endpoints + logger *zap.Logger + m *metrics.Metrics +} + +// NewS3Storage builds an S3-backed StorageX. An empty cfg.Endpoint targets AWS +// S3; a non-empty one (with path-style addressing) targets MinIO or any +// S3-compatible store. +func NewS3Storage(ctx context.Context, cfg Config, m *metrics.Metrics, logger *zap.Logger) (StorageX, error) { + region := cfg.Region + if region == "" { + region = "us-east-1" + } + + loadOpts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(region)} + if cfg.AccessKeyID != "" { + loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, cfg.SessionToken), + )) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOpts...) + if err != nil { + return nil, errors.NewInternalServerError("Failed to load AWS config", err) + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + if cfg.Endpoint != "" { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = true // MinIO and most S3-compatible stores need path-style + } + }) + + return &s3Storage{ + client: client, + presign: s3.NewPresignClient(client), + region: region, + endpoint: cfg.Endpoint, + logger: logger, + m: m, + }, nil +} + +func (s *s3Storage) PutObject(ctx context.Context, bucket, key string, data io.Reader, options *PutOptions) error { + tracer := otel.Tracer("mpiper-api") + ctx, span := tracer.Start(ctx, "S3.PutObject") + defer span.End() + + start := time.Now() + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + in := &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), Body: data} + if options != nil { + if options.ContentType != "" { + in.ContentType = aws.String(options.ContentType) + } + if options.Metadata != nil { + in.Metadata = options.Metadata + } + } + + if _, err := s.client.PutObject(ctx, in); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Failed to put object") + s.recordOperationMetrics(ctx, "put", false, time.Since(start)) + return err + } + + s.recordOperationMetrics(ctx, "put", true, time.Since(start)) + span.SetStatus(codes.Ok, "Object uploaded successfully") + return nil +} + +func (s *s3Storage) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) { + tracer := otel.Tracer("mpiper-api") + ctx, span := tracer.Start(ctx, "S3.GetObject") + defer span.End() + + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Failed to get object") + return nil, err + } + + span.SetStatus(codes.Ok, "Object reader created") + return out.Body, nil +} + +func (s *s3Storage) GetObjectAttrs(ctx context.Context, bucket, key string) (*ObjectAttrs, error) { + tracer := otel.Tracer("mpiper-api") + ctx, span := tracer.Start(ctx, "S3.GetObjectAttrs") + defer span.End() + + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Failed to get object attributes") + return nil, err + } + + attrs := &ObjectAttrs{ + Size: aws.ToInt64(head.ContentLength), + ContentType: aws.ToString(head.ContentType), + ETag: aws.ToString(head.ETag), + } + span.SetAttributes( + attribute.Int64("object.size", attrs.Size), + attribute.String("object.content_type", attrs.ContentType), + ) + span.SetStatus(codes.Ok, "Object attributes retrieved") + return attrs, nil +} + +func (s *s3Storage) GeneratePresignedURL(ctx context.Context, bucket, key string, options *PresignedURLOptions) (string, error) { + tracer := otel.Tracer("mpiper-api") + ctx, span := tracer.Start(ctx, "S3.GeneratePresignedURL") + defer span.End() + + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + if options == nil { + options = &PresignedURLOptions{} + } + + expiresIn := time.Duration(options.ExpiresInSeconds) * time.Second + if expiresIn == 0 { + expiresIn = 15 * time.Minute + } + withExpiry := s3.WithPresignExpires(expiresIn) + + var ( + url string + err error + ) + switch strings.ToUpper(options.Method) { + case "", "PUT": + in := &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)} + if options.ContentType != "" { + in.ContentType = aws.String(options.ContentType) + } + var req *v4.PresignedHTTPRequest + if req, err = s.presign.PresignPutObject(ctx, in, withExpiry); req != nil { + url = req.URL + } + case "GET": + var req *v4.PresignedHTTPRequest + if req, err = s.presign.PresignGetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}, withExpiry); req != nil { + url = req.URL + } + default: + return "", errors.NewInternalServerError("Unsupported presign method", fmt.Errorf("method %q", options.Method)) + } + + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Failed to generate presigned URL") + return "", errors.NewInternalServerError("Failed to generate presigned URL", err) + } + + span.SetStatus(codes.Ok, "Presigned URL generated") + return url, nil +} + +func (s *s3Storage) PublicURL(ctx context.Context, bucket, key string) (string, error) { + _, span := otel.Tracer("mpiper-api").Start(ctx, "S3.PublicURL") + defer span.End() + + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + var url string + if s.endpoint != "" { + // path-style for MinIO / S3-compatible endpoints + url = fmt.Sprintf("%s/%s/%s", strings.TrimRight(s.endpoint, "/"), bucket, key) + } else { + url = fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, s.region, key) + } + span.SetStatus(codes.Ok, "Public URL generated") + return url, nil +} + +func (s *s3Storage) DeleteObject(ctx context.Context, bucket, key string) error { + tracer := otel.Tracer("mpiper-api") + ctx, span := tracer.Start(ctx, "S3.DeleteObject") + defer span.End() + + span.SetAttributes( + attribute.String("storage.bucket", bucket), + attribute.String("storage.key", key), + attribute.String("storage.provider", "s3"), + ) + + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Failed to delete object") + return err + } + + span.SetStatus(codes.Ok, "Object deleted successfully") + return nil +} + +func (s *s3Storage) recordOperationMetrics(ctx context.Context, operation string, success bool, duration time.Duration) { + if s.m == nil { + return + } + status := "success" + if !success { + status = "error" + } + attrs := []attribute.KeyValue{ + attribute.String("storage.operation", operation), + attribute.String("storage.provider", "s3"), + attribute.String("storage.status", status), + } + if success { + s.m.StorageOperationTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) + } else { + s.m.StorageOperationErrors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + s.m.StorageOperationDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(attrs...)) +} diff --git a/pkg/utils/storagex/s3_test.go b/pkg/utils/storagex/s3_test.go new file mode 100644 index 0000000..2dcf6d7 --- /dev/null +++ b/pkg/utils/storagex/s3_test.go @@ -0,0 +1,109 @@ +package storagex + +import ( + "context" + "io" + "os" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "go.uber.org/zap" +) + +// TestS3StorageRoundTrip exercises the S3 impl against a real S3-compatible +// store (MinIO). It is skipped unless S3_TEST_ENDPOINT is set, so it never +// runs in CI without an endpoint. +// +// S3_TEST_ENDPOINT=http://localhost:9000 S3_TEST_ACCESS_KEY=... \ +// S3_TEST_SECRET_KEY=... S3_TEST_BUCKET=test-bucket go test ./pkg/utils/storagex/ -run TestS3 +func TestS3StorageRoundTrip(t *testing.T) { + endpoint := os.Getenv("S3_TEST_ENDPOINT") + if endpoint == "" { + t.Skip("set S3_TEST_ENDPOINT to run the MinIO integration test") + } + ak := os.Getenv("S3_TEST_ACCESS_KEY") + sk := os.Getenv("S3_TEST_SECRET_KEY") + bucket := os.Getenv("S3_TEST_BUCKET") + ctx := context.Background() + + cfg := Config{ + Provider: S3Provider, + Region: "us-east-1", + Endpoint: endpoint, + Bucket: bucket, + AccessKeyID: ak, + SecretAccessKey: sk, + } + + // Ensure the bucket exists (create via a raw client; ignore if it already does). + createTestBucket(t, ctx, cfg, bucket) + + st, err := NewS3Storage(ctx, cfg, nil, zap.NewNop()) + if err != nil { + t.Fatalf("NewS3Storage: %v", err) + } + + key := "test/roundtrip.txt" + body := "hello mpiper s3" + + if err := st.PutObject(ctx, bucket, key, strings.NewReader(body), &PutOptions{ContentType: "text/plain"}); err != nil { + t.Fatalf("PutObject: %v", err) + } + + attrs, err := st.GetObjectAttrs(ctx, bucket, key) + if err != nil { + t.Fatalf("GetObjectAttrs: %v", err) + } + if attrs.Size != int64(len(body)) { + t.Errorf("size = %d, want %d", attrs.Size, len(body)) + } + if attrs.ContentType != "text/plain" { + t.Errorf("contentType = %q, want text/plain", attrs.ContentType) + } + + rc, err := st.GetObject(ctx, bucket, key) + if err != nil { + t.Fatalf("GetObject: %v", err) + } + got, _ := io.ReadAll(rc) + _ = rc.Close() + if string(got) != body { + t.Errorf("body = %q, want %q", got, body) + } + + url, err := st.GeneratePresignedURL(ctx, bucket, key, &PresignedURLOptions{Method: "GET", ExpiresInSeconds: 60}) + if err != nil { + t.Fatalf("GeneratePresignedURL: %v", err) + } + if !strings.Contains(url, key) || !strings.Contains(url, "X-Amz-Signature") { + t.Errorf("presigned url looks wrong: %s", url) + } + + if err := st.DeleteObject(ctx, bucket, key); err != nil { + t.Fatalf("DeleteObject: %v", err) + } + if _, err := st.GetObjectAttrs(ctx, bucket, key); err == nil { + t.Error("GetObjectAttrs after delete: want error, got nil") + } +} + +func createTestBucket(t *testing.T, ctx context.Context, cfg Config, bucket string) { + t.Helper() + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(cfg.Region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")), + ) + if err != nil { + t.Fatalf("load aws config: %v", err) + } + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = true + }) + // Ignore error: bucket may already exist. + _, _ = client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}) +} diff --git a/pkg/utils/storagex/storagex.go b/pkg/utils/storagex/storagex.go index 55aacbc..68a4afc 100644 --- a/pkg/utils/storagex/storagex.go +++ b/pkg/utils/storagex/storagex.go @@ -4,8 +4,6 @@ import ( "context" "io" "time" - - "cloud.google.com/go/storage" ) type PutOptions struct { @@ -21,10 +19,19 @@ type PresignedURLOptions struct { // Add other options as needed: Headers, QueryParams, etc. } +// ObjectAttrs is a provider-agnostic subset of object metadata. Concrete +// providers (GCS, S3) map their native attributes into this shape so the +// StorageX interface stays free of provider-specific types. +type ObjectAttrs struct { + Size int64 + ContentType string + ETag string +} + type StorageX interface { PutObject(ctx context.Context, bucket, key string, data io.Reader, options *PutOptions) error GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) - GetObjectAttrs(ctx context.Context, bucket, key string) (*storage.ObjectAttrs, error) + GetObjectAttrs(ctx context.Context, bucket, key string) (*ObjectAttrs, error) GeneratePresignedURL(ctx context.Context, bucket, key string, options *PresignedURLOptions) (string, error) PublicURL(ctx context.Context, bucket, key string) (string, error) DeleteObject(ctx context.Context, bucket, key string) error From 0b39d462e8935cb97b4484d6b6a83be3e0a6454e Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 17 Jun 2026 06:20:00 +0530 Subject: [PATCH 18/19] feat(worker): add S3Storage provider, factory, and public_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the S3/MinIO storage backend for the Python worker, mirroring the Go side so a single .env drives both services for local e2e. - s3.py: S3Storage(StorageX) on boto3; path-style + endpoint_url for MinIO, virtual-host URLs for AWS — public_url matches pkg/utils/storagex/s3.go - base.py/gcs.py: add public_url to the StorageX ABC + GCS impl - storage/__init__.py: get_storage(cfg) factory keyed on bucket.provider - main.py: use the factory; drop hardcoded GCS get_storage - config.py: read S3_* env vars first, BUCKET_* fallback (mirrors env.go) - images.py/videos.py: replace hardcoded storage.googleapis.com URLs with storage.public_url(key) so DB URLs are correct under MinIO - pyproject: add boto3, fix readme path, set package-mode = false Closes DEV-49 Co-Authored-By: Claude Opus 4.8 (1M context) --- poetry.lock | 602 ++++++++++++++++++++++++++++++++---- pyproject.toml | 4 +- worker/consumer/config.py | 16 +- worker/consumer/main.py | 13 +- worker/processing/images.py | 2 +- worker/processing/videos.py | 2 +- worker/storage/__init__.py | 20 ++ worker/storage/base.py | 5 + worker/storage/gcs.py | 4 + worker/storage/s3.py | 78 +++++ 10 files changed, 661 insertions(+), 85 deletions(-) create mode 100644 worker/storage/__init__.py create mode 100644 worker/storage/s3.py diff --git a/poetry.lock b/poetry.lock index fdb8aa6..b09c3a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,16 @@ -# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] [[package]] name = "async-timeout" @@ -6,17 +18,32 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version < \"3.11.3\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + [[package]] name = "black" version = "25.12.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"}, {file = "black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a"}, @@ -63,12 +90,53 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "boto3" +version = "1.43.31" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "boto3-1.43.31-py3-none-any.whl", hash = "sha256:69c5521ad864f33d4d53b0e18a3927697f4558210693b1cb4dd97da959d1f7b9"}, + {file = "boto3-1.43.31.tar.gz", hash = "sha256:8165b79c02955affbe4b4e9aa7c560684d0d4d86b4b9de66a37b45eb79fc4b69"}, +] + +[package.dependencies] +botocore = ">=1.43.31,<1.44.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.19.0,<0.20.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.43.31" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "botocore-1.43.31-py3-none-any.whl", hash = "sha256:4c51c63f39515fc1a2b8e3e2c29e452009c988ba55ad489251658fdd3aedad6e"}, + {file = "botocore-1.43.31.tar.gz", hash = "sha256:c249625faaa353c5b4004043706a394a4f3bcd3643e242f6b01fff6dc70e988b"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" + +[package.extras] +crt = ["awscrt (==0.32.2)"] + [[package]] name = "cachetools" version = "6.2.3" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "cachetools-6.2.3-py3-none-any.whl", hash = "sha256:3fde34f7033979efb1e79b07ae529c2c40808bdd23b0b731405a48439254fba5"}, {file = "cachetools-6.2.3.tar.gz", hash = "sha256:64e0a4ddf275041dd01f5b873efa87c91ea49022b844b8c5d1ad3407c0f42f1f"}, @@ -80,6 +148,7 @@ version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, @@ -91,6 +160,7 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -213,6 +283,7 @@ version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -227,6 +298,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -238,6 +311,7 @@ version = "2.28.1" description = "Google API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"}, {file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"}, @@ -247,7 +321,7 @@ files = [ google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" proto-plus = [ - {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" @@ -255,7 +329,7 @@ requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)", "grpcio-status (>=1.75.1,<2.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -265,6 +339,7 @@ version = "2.43.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"}, {file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"}, @@ -278,11 +353,11 @@ rsa = ">=3.1.4,<5" [package.extras] aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0)", "cryptography (<39.0.0)", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] [[package]] @@ -291,6 +366,7 @@ version = "2.5.0" description = "Google Cloud API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc"}, {file = "google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963"}, @@ -301,7 +377,7 @@ google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0" google-auth = ">=1.25.0,<3.0.0" [package.extras] -grpc = ["grpcio (>=1.38.0,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.38.0,<2.0.0)"] +grpc = ["grpcio (>=1.38.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.38.0,<2.0.0)"] [[package]] name = "google-cloud-storage" @@ -309,6 +385,7 @@ version = "3.7.0" description = "Google Cloud Storage API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_cloud_storage-3.7.0-py3-none-any.whl", hash = "sha256:469bc9540936e02f8a4bfd1619e9dca1e42dec48f95e4204d783b36476a15093"}, {file = "google_cloud_storage-3.7.0.tar.gz", hash = "sha256:9ce59c65f4d6e372effcecc0456680a8d73cef4f2dc9212a0704799cb3d69237"}, @@ -323,7 +400,7 @@ google-resumable-media = ">=2.7.2,<3.0.0" requests = ">=2.22.0,<3.0.0" [package.extras] -grpc = ["google-api-core[grpc] (>=2.27.0,<3.0.0)", "grpc-google-iam-v1 (>=0.14.0,<1.0.0)", "grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.76.0,<2.0.0)", "proto-plus (>=1.22.3,<2.0.0)", "proto-plus (>=1.25.0,<2.0.0)", "protobuf (>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0)"] +grpc = ["google-api-core[grpc] (>=2.27.0,<3.0.0)", "grpc-google-iam-v1 (>=0.14.0,<1.0.0)", "grpcio (>=1.33.2,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.76.0,<2.0.0)", "proto-plus (>=1.22.3,<2.0.0) ; python_version < \"3.13\"", "proto-plus (>=1.25.0,<2.0.0) ; python_version >= \"3.13\"", "protobuf (>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0)"] protobuf = ["protobuf (>=3.20.2,<7.0.0)"] tracing = ["opentelemetry-api (>=1.1.0,<2.0.0)"] @@ -333,6 +410,7 @@ version = "1.7.1" description = "A python wrapper of the C library 'Google CRC32C'" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76"}, {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d"}, @@ -379,6 +457,7 @@ version = "2.8.0" description = "Utilities for Google Media Downloads and Resumable Uploads" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582"}, {file = "google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae"}, @@ -397,6 +476,7 @@ version = "1.72.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, @@ -414,6 +494,7 @@ version = "1.76.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, @@ -490,6 +571,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -504,6 +586,7 @@ version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -513,13 +596,25 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] [[package]] name = "mypy-extensions" @@ -527,6 +622,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -538,6 +634,7 @@ version = "1.39.1" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, @@ -547,27 +644,13 @@ files = [ importlib-metadata = ">=6.0,<8.8.0" typing-extensions = ">=4.5.0" -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.39.1" -description = "OpenTelemetry Collector Exporters" -optional = false -python-versions = ">=3.9" -files = [ - {file = "opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe"}, - {file = "opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c"}, -] - -[package.dependencies] -opentelemetry-exporter-otlp-proto-grpc = "1.39.1" -opentelemetry-exporter-otlp-proto-http = "1.39.1" - [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.39.1" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"}, {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"}, @@ -582,6 +665,7 @@ version = "1.39.1" description = "OpenTelemetry Collector Protobuf over gRPC Exporter" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad"}, @@ -603,27 +687,22 @@ typing-extensions = ">=4.6.0" gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] [[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +name = "opentelemetry-instrumentation" +version = "0.60b1" +description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"}, + {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"}, + {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"}, ] [package.dependencies] -googleapis-common-protos = ">=1.52,<2.0" -opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.39.1" -opentelemetry-proto = "1.39.1" -opentelemetry-sdk = ">=1.39.1,<1.40.0" -requests = ">=2.7,<3.0" -typing-extensions = ">=4.5.0" - -[package.extras] -gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] +opentelemetry-api = ">=1.4,<2.0" +opentelemetry-semantic-conventions = "0.60b1" +packaging = ">=18.0" +wrapt = ">=1.0.0,<2.0.0" [[package]] name = "opentelemetry-proto" @@ -631,6 +710,7 @@ version = "1.39.1" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"}, {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"}, @@ -645,6 +725,7 @@ version = "1.39.1" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, @@ -661,6 +742,7 @@ version = "0.60b1" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, @@ -676,6 +758,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -687,6 +770,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -698,6 +782,7 @@ version = "12.0.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, @@ -806,6 +891,7 @@ version = "4.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, @@ -822,6 +908,7 @@ version = "1.26.1" description = "Beautiful, Pythonic protocol buffers" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, @@ -839,6 +926,7 @@ version = "6.33.2" description = "" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, @@ -858,46 +946,98 @@ version = "3.3.2" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, ] [package.dependencies] +psycopg-binary = {version = "3.3.2", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.3.2)"] -c = ["psycopg-c (==3.3.2)"] +binary = ["psycopg-binary (==3.3.2) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.3.2) ; implementation_name != \"pypy\""] dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "cython-lint (>=0.16)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.19.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] -test = ["anyio (>=4.0)", "mypy (>=1.19.0)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +test = ["anyio (>=4.0)", "mypy (>=1.19.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] -name = "psycopg-pool" -version = "3.3.0" -description = "Connection Pool for Psycopg" +name = "psycopg-binary" +version = "3.3.2" +description = "PostgreSQL database adapter for Python -- C optimisation distribution" optional = false python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"pypy\"" files = [ - {file = "psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063"}, - {file = "psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1"}, ] -[package.dependencies] -typing-extensions = ">=4.6" - -[package.extras] -test = ["anyio (>=4.0)", "mypy (>=1.14)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] - [[package]] name = "pyasn1" version = "0.6.1" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -909,6 +1049,7 @@ version = "0.4.2" description = "A collection of ASN.1-based protocols modules" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, @@ -918,18 +1059,186 @@ files = [ pyasn1 = ">=0.6.1,<0.7.0" [[package]] -name = "python-dotenv" -version = "1.2.1" -description = "Read key-value pairs from a .env file and set them as environment variables" +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, - {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + [package.extras] -cli = ["click (>=5.0)"] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-magic" +version = "0.4.27" +description = "File type identification using libmagic" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"}, + {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"}, +] [[package]] name = "pytokens" @@ -937,6 +1246,7 @@ version = "0.3.0" description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, @@ -951,6 +1261,7 @@ version = "7.1.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"}, {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"}, @@ -971,6 +1282,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -992,6 +1304,7 @@ version = "4.2" description = "Pure-Python RSA implementation" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, ] @@ -999,12 +1312,59 @@ files = [ [package.dependencies] pyasn1 = ">=0.1.3" +[[package]] +name = "s3transfer" +version = "0.19.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262"}, + {file = "s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "structlog" +version = "26.1.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e"}, + {file = "structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} + [[package]] name = "tomli" version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, @@ -1056,10 +1416,27 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version == \"3.10\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" [[package]] name = "tzdata" @@ -1067,6 +1444,8 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -1078,16 +1457,108 @@ version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "wrapt" +version = "1.17.3" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, + {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, + {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, + {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, + {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, + {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, + {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, + {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, + {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, + {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, + {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, + {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, + {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, + {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, + {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, + {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, + {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"}, + {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"}, + {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"}, + {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"}, + {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"}, + {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"}, + {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, + {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, +] [[package]] name = "zipp" @@ -1095,13 +1566,14 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -1109,6 +1581,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.10" -content-hash = "04c8167f6a04409f489b5e066eec394cbb6670cc3e553b7effff3c28e0b3d3cf" +content-hash = "286c207876381c50bc2d44ddef0f8adf1dfccb1a16850287a2f1d184d17e1f89" diff --git a/pyproject.toml b/pyproject.toml index 86859ee..5f74981 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Shantanu Mane",email = "shantanu.mane.200@outlook.com"} ] license = {file = "LICENSE"} -readme = "README" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "psycopg[binary]", @@ -30,6 +30,7 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] +package-mode = false name = "mpiper" version = "0.1.0" description = "A consumer for the mpiper queue system" @@ -42,6 +43,7 @@ redis = "^7.1.0" pillow = "^12.0.0" psycopg-pool = "^3.3.0" google-cloud-storage = "^3.7.0" +boto3 = "^1.35.0" python-dotenv = "^1.2.1" opentelemetry-api = "^1.39.1" opentelemetry-sdk = "^1.39.1" diff --git a/worker/consumer/config.py b/worker/consumer/config.py index e6dacbc..49d78c3 100644 --- a/worker/consumer/config.py +++ b/worker/consumer/config.py @@ -76,14 +76,18 @@ class BucketConfig: @staticmethod def from_env() -> "BucketConfig": + # S3_* names mirror the Go server (internal/config/env.go); they take + # precedence over the generic BUCKET_* names so a single .env drives + # both services. BUCKET_* remains the fallback / GCS default. + bucket_name = os.getenv("S3_BUCKET_NAME") or os.getenv("BUCKET_NAME", "media-bucket") return BucketConfig( provider=os.getenv("BUCKET_PROVIDER", "gcs"), - bucket_name=os.getenv("BUCKET_NAME", "media-bucket"), - region=os.getenv("BUCKET_REGION", "us-east-1"), - access_key=os.getenv("BUCKET_ACCESS_KEY", ""), - secret_key=os.getenv("BUCKET_SECRET_KEY", ""), - endpoint_url=os.getenv("BUCKET_ENDPOINT_URL"), - sa_path=os.getenv("BUCKET_SA_PATH"), + bucket_name=bucket_name, + region=os.getenv("S3_REGION") or os.getenv("BUCKET_REGION", "us-east-1"), + access_key=os.getenv("S3_ACCESS_KEY_ID") or os.getenv("BUCKET_ACCESS_KEY", ""), + secret_key=os.getenv("S3_SECRET_ACCESS_KEY") or os.getenv("BUCKET_SECRET_KEY", ""), + endpoint_url=os.getenv("S3_ENDPOINT_URL") or os.getenv("BUCKET_ENDPOINT_URL"), + sa_path=os.getenv("GCS_SA_PATH") or os.getenv("BUCKET_SA_PATH"), ) diff --git a/worker/consumer/main.py b/worker/consumer/main.py index 12b2159..a378c58 100644 --- a/worker/consumer/main.py +++ b/worker/consumer/main.py @@ -4,12 +4,11 @@ from urllib.parse import quote_plus -from worker.consumer.config import WorkerConfig, get_config +from worker.consumer.config import get_config from worker.consumer.consumer import Consumer from worker.consumer.db import PgPool from worker.consumer.migrations import run_migrations -from worker.storage.base import StorageX -from worker.storage.gcs import GCSStorage +from worker.storage import get_storage from worker.utils import metrics as worker_metrics logger = logging.getLogger(__name__) @@ -69,11 +68,3 @@ def _term(signum, frame): if __name__ == "__main__": main() - - -def get_storage(cfg: WorkerConfig) -> StorageX: - return GCSStorage(cfg.bucket.bucket_name, cfg.bucket.sa_path) - - -if __name__ == "__main__": - main() diff --git a/worker/processing/images.py b/worker/processing/images.py index 1b1b43c..af7060b 100644 --- a/worker/processing/images.py +++ b/worker/processing/images.py @@ -94,7 +94,7 @@ def process_image_file( variant_hash = compute_variant_hash(content_hash, params) key = f"media/processed/{content_hash}/{variant_hash}.{v['format']}" - url = f"https://storage.googleapis.com/{cfg.bucket.bucket_name}/{key}" + url = storage.public_url(key) with pg_pool.get_pg_conn() as conn: cur = conn.cursor() diff --git a/worker/processing/videos.py b/worker/processing/videos.py index af754b6..bd8474a 100644 --- a/worker/processing/videos.py +++ b/worker/processing/videos.py @@ -60,7 +60,7 @@ def ensure_variant_exists( # CORRECT: Storage key uses variant_hash, not content_hash in path # This way, identical variants from different content share the same file key = f"media/variants/{variant_hash[:2]}/{variant_hash}.{ext}" - url = f"https://storage.googleapis.com/{cfg.bucket.bucket_name}/{key}" + url = storage.public_url(key) with pg_pool.get_pg_conn() as conn: cur = conn.cursor() diff --git a/worker/storage/__init__.py b/worker/storage/__init__.py new file mode 100644 index 0000000..110c6d5 --- /dev/null +++ b/worker/storage/__init__.py @@ -0,0 +1,20 @@ +from worker.consumer.config import WorkerConfig +from worker.storage.base import StorageX +from worker.storage.gcs import GCSStorage +from worker.storage.s3 import S3Storage + + +def get_storage(cfg: WorkerConfig) -> StorageX: + """Construct the storage backend for the configured provider.""" + b = cfg.bucket + if b.provider == "gcs": + return GCSStorage(b.bucket_name, b.sa_path) + if b.provider == "s3": + return S3Storage( + bucket_name=b.bucket_name, + region=b.region, + access_key=b.access_key, + secret_key=b.secret_key, + endpoint_url=b.endpoint_url, + ) + raise ValueError(f"unknown storage provider: {b.provider}") diff --git a/worker/storage/base.py b/worker/storage/base.py index ca135f0..e8440fa 100644 --- a/worker/storage/base.py +++ b/worker/storage/base.py @@ -39,3 +39,8 @@ def get_metadata(self, key: str) -> Dict[str, Any]: def exists(self, key: str) -> bool: """Check if a key exists in storage.""" pass + + @abstractmethod + def public_url(self, key: str) -> str: + """Return the public (unsigned) URL for an object key.""" + pass diff --git a/worker/storage/gcs.py b/worker/storage/gcs.py index 87d630f..8a5b861 100644 --- a/worker/storage/gcs.py +++ b/worker/storage/gcs.py @@ -17,6 +17,7 @@ class GCSStorage(StorageX): def __init__(self, bucket_name: str, sa_path: str): self.client = _create_gcs_client(sa_path) self.bucket = self.client.bucket(bucket_name) + self.bucket_name = bucket_name def upload_bytes( self, key: str, data: bytes, content_type: Optional[Any] = None @@ -47,3 +48,6 @@ def get_metadata(self, key: str) -> Dict[str, Any]: def exists(self, key: str) -> bool: blob = self.bucket.blob(key) return blob.exists() + + def public_url(self, key: str) -> str: + return f"https://storage.googleapis.com/{self.bucket_name}/{key}" diff --git a/worker/storage/s3.py b/worker/storage/s3.py new file mode 100644 index 0000000..519d74b --- /dev/null +++ b/worker/storage/s3.py @@ -0,0 +1,78 @@ +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.client import Config +from botocore.exceptions import ClientError + +from worker.storage.base import StorageX + + +class S3Storage(StorageX): + """S3 / S3-compatible (MinIO) storage backed by boto3. + + When ``endpoint_url`` is set, path-style addressing is used so the same + client works against MinIO and other S3-compatible stores. + """ + + def __init__( + self, + bucket_name: str, + region: str, + access_key: str, + secret_key: str, + endpoint_url: Optional[str] = None, + ): + self.bucket_name = bucket_name + self.region = region + self.endpoint_url = endpoint_url or None + + self.client = boto3.client( + "s3", + region_name=region or None, + aws_access_key_id=access_key or None, + aws_secret_access_key=secret_key or None, + endpoint_url=self.endpoint_url, + config=Config(s3={"addressing_style": "path"}) if self.endpoint_url else None, + ) + + def upload_bytes( + self, key: str, data: bytes, content_type: Optional[Any] = None + ) -> None: + extra = {"ContentType": content_type} if content_type else {} + self.client.put_object(Bucket=self.bucket_name, Key=key, Body=data, **extra) + + def download_bytes(self, key: str) -> bytes: + resp = self.client.get_object(Bucket=self.bucket_name, Key=key) + return resp["Body"].read() + + def download_to_file(self, key: str, file_path: str) -> None: + self.client.download_file(self.bucket_name, key, file_path) + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.bucket_name, Key=key) + + def list_keys(self) -> List[str]: + keys: List[str] = [] + paginator = self.client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self.bucket_name): + keys.extend(obj["Key"] for obj in page.get("Contents", [])) + return keys + + def get_metadata(self, key: str) -> Dict[str, Any]: + resp = self.client.head_object(Bucket=self.bucket_name, Key=key) + return resp.get("Metadata", {}) + + def exists(self, key: str) -> bool: + try: + self.client.head_object(Bucket=self.bucket_name, Key=key) + return True + except ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + return False + raise + + def public_url(self, key: str) -> str: + if self.endpoint_url: + # path-style for MinIO / S3-compatible endpoints + return f"{self.endpoint_url.rstrip('/')}/{self.bucket_name}/{key}" + return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{key}" From 606b2459dbf2f4abb3da35a95c93874ee20bc192 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Wed, 17 Jun 2026 06:28:03 +0530 Subject: [PATCH 19/19] chore(release): bump version to 1.0.0 Set .version to 1.0.0 so CI (build-and-push + release-lts) stamps the 1.0.0 images and ldflags-embedded main.Version. Update the Go local-run fallback and pyproject version to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .version | 2 +- cmd/server/main.go | 2 +- pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.version b/.version index 6e8bf73..3eefcb9 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.1.0 +1.0.0 diff --git a/cmd/server/main.go b/cmd/server/main.go index c012e06..f3c835b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,7 @@ import ( var ( Env = "development" - Version = "0.1.0" + Version = "1.0.0" CommitHash = "abc1234" BuildTime = "2024-06-01T12:00:00Z" Author = "RndmCodeGuy" diff --git a/pyproject.toml b/pyproject.toml index 5f74981..6df13f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mpiper" -version = "0.1.0" +version = "1.0.0" description = "A consumer for the mpiper queue system" authors = [ {name = "Shantanu Mane",email = "shantanu.mane.200@outlook.com"} @@ -32,7 +32,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] package-mode = false name = "mpiper" -version = "0.1.0" +version = "1.0.0" description = "A consumer for the mpiper queue system" authors = ["Shantanu Mane "]