diff --git a/docker-compose.yml b/docker-compose.yml index 1dbdda0..0d79fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,7 @@ services: - "3000:3000" volumes: - ./services/dashboard/src:/app/src:ro + - ./services/dashboard/public:/app/public:ro depends_on: - api networks: diff --git a/infrastructure/terraform/environments/dev/main.tf b/infrastructure/terraform/environments/dev/main.tf index 19c0571..63219f2 100644 --- a/infrastructure/terraform/environments/dev/main.tf +++ b/infrastructure/terraform/environments/dev/main.tf @@ -114,12 +114,12 @@ data "aws_availability_zones" "available" { module "vpc_primary" { source = "../../modules/vpc" - name_prefix = local.name_prefix - cidr_block = local.regions["us-east-1"].cidr_block - az_count = local.regions["us-east-1"].az_count - environment = local.environment - enable_nat = true # Single NAT for dev (cost optimization) - single_nat = true + name_prefix = local.name_prefix + cidr_block = local.regions["us-east-1"].cidr_block + az_count = local.regions["us-east-1"].az_count + environment = local.environment + enable_nat = true # Single NAT for dev (cost optimization) + single_nat = true tags = local.common_tags } @@ -131,22 +131,22 @@ module "vpc_primary" { module "rds_primary" { source = "../../modules/rds" - name_prefix = local.name_prefix - vpc_id = module.vpc_primary.vpc_id - subnet_ids = module.vpc_primary.private_subnet_ids - security_group_ids = [module.vpc_primary.rds_security_group_id] + name_prefix = local.name_prefix + vpc_id = module.vpc_primary.vpc_id + subnet_ids = module.vpc_primary.private_subnet_ids + security_group_ids = [module.vpc_primary.rds_security_group_id] - instance_class = "db.t3.micro" - allocated_storage = 20 - engine_version = "15" - multi_az = false # Single AZ for dev - backup_retention = 7 + instance_class = "db.t3.micro" + allocated_storage = 20 + engine_version = "15" + multi_az = false # Single AZ for dev + backup_retention = 7 - database_name = "redisaas" - master_username = "redisaas_admin" + database_name = "redisaas" + master_username = "redisaas_admin" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -161,12 +161,12 @@ module "elasticache_primary" { subnet_ids = module.vpc_primary.private_subnet_ids security_group_ids = [module.vpc_primary.redis_security_group_id] - node_type = "cache.t3.micro" - num_cache_nodes = 1 # Single node for dev - engine_version = "7.0" + node_type = "cache.t3.micro" + num_cache_nodes = 1 # Single node for dev + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= diff --git a/infrastructure/terraform/environments/prod/main.tf b/infrastructure/terraform/environments/prod/main.tf index f273b61..d800907 100644 --- a/infrastructure/terraform/environments/prod/main.tf +++ b/infrastructure/terraform/environments/prod/main.tf @@ -121,12 +121,12 @@ data "aws_caller_identity" "current" {} module "vpc_us" { source = "../../modules/vpc" - name_prefix = "${local.name_prefix}-us" - cidr_block = local.regions["us-east-1"].cidr_block - az_count = local.regions["us-east-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false # HA NAT for prod + name_prefix = "${local.name_prefix}-us" + cidr_block = local.regions["us-east-1"].cidr_block + az_count = local.regions["us-east-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false # HA NAT for prod tags = local.common_tags } @@ -141,12 +141,12 @@ module "vpc_eu" { aws = aws.eu } - name_prefix = "${local.name_prefix}-eu" - cidr_block = local.regions["eu-central-1"].cidr_block - az_count = local.regions["eu-central-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false + name_prefix = "${local.name_prefix}-eu" + cidr_block = local.regions["eu-central-1"].cidr_block + az_count = local.regions["eu-central-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false tags = local.common_tags } @@ -161,12 +161,12 @@ module "vpc_ap" { aws = aws.ap } - name_prefix = "${local.name_prefix}-ap" - cidr_block = local.regions["ap-southeast-1"].cidr_block - az_count = local.regions["ap-southeast-1"].az_count - environment = local.environment - enable_nat = true - single_nat = false + name_prefix = "${local.name_prefix}-ap" + cidr_block = local.regions["ap-southeast-1"].cidr_block + az_count = local.regions["ap-southeast-1"].az_count + environment = local.environment + enable_nat = true + single_nat = false tags = local.common_tags } @@ -178,22 +178,22 @@ module "vpc_ap" { module "rds_primary" { source = "../../modules/rds" - name_prefix = "${local.name_prefix}-us" - vpc_id = module.vpc_us.vpc_id - subnet_ids = module.vpc_us.private_subnet_ids - security_group_ids = [module.vpc_us.rds_security_group_id] + name_prefix = "${local.name_prefix}-us" + vpc_id = module.vpc_us.vpc_id + subnet_ids = module.vpc_us.private_subnet_ids + security_group_ids = [module.vpc_us.rds_security_group_id] - instance_class = "db.t3.small" # Upgraded for prod - allocated_storage = 50 - engine_version = "15" - multi_az = true # HA for prod - backup_retention = 14 + instance_class = "db.t3.small" # Upgraded for prod + allocated_storage = 50 + engine_version = "15" + multi_az = true # HA for prod + backup_retention = 14 - database_name = "redisaas" - master_username = "redisaas_admin" + database_name = "redisaas" + master_username = "redisaas_admin" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -208,12 +208,12 @@ module "elasticache_us" { subnet_ids = module.vpc_us.private_subnet_ids security_group_ids = [module.vpc_us.redis_security_group_id] - node_type = "cache.t3.small" # Upgraded for prod - num_cache_nodes = 2 # Primary + replica - engine_version = "7.0" + node_type = "cache.t3.small" # Upgraded for prod + num_cache_nodes = 2 # Primary + replica + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -231,12 +231,12 @@ module "elasticache_eu" { subnet_ids = module.vpc_eu.private_subnet_ids security_group_ids = [module.vpc_eu.redis_security_group_id] - node_type = "cache.t3.small" - num_cache_nodes = 2 - engine_version = "7.0" + node_type = "cache.t3.small" + num_cache_nodes = 2 + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= @@ -254,12 +254,12 @@ module "elasticache_ap" { subnet_ids = module.vpc_ap.private_subnet_ids security_group_ids = [module.vpc_ap.redis_security_group_id] - node_type = "cache.t3.small" - num_cache_nodes = 2 - engine_version = "7.0" + node_type = "cache.t3.small" + num_cache_nodes = 2 + engine_version = "7.0" - environment = local.environment - tags = local.common_tags + environment = local.environment + tags = local.common_tags } # ============================================================================= diff --git a/infrastructure/terraform/modules/elasticache/main.tf b/infrastructure/terraform/modules/elasticache/main.tf index 4c2fe16..150c2fa 100644 --- a/infrastructure/terraform/modules/elasticache/main.tf +++ b/infrastructure/terraform/modules/elasticache/main.tf @@ -7,8 +7,8 @@ # ============================================================================= resource "random_password" "auth_token" { - length = 32 - special = false # ElastiCache auth token doesn't support all special chars + length = 32 + special = false # ElastiCache auth token doesn't support all special chars } # ============================================================================= diff --git a/infrastructure/terraform/modules/rds/main.tf b/infrastructure/terraform/modules/rds/main.tf index 4160cbe..5055262 100644 --- a/infrastructure/terraform/modules/rds/main.tf +++ b/infrastructure/terraform/modules/rds/main.tf @@ -62,22 +62,22 @@ resource "aws_db_parameter_group" "main" { # Performance parameters parameter { name = "shared_buffers" - value = "{DBInstanceClassMemory/4096}" # 25% of memory + value = "{DBInstanceClassMemory/4096}" # 25% of memory } parameter { name = "effective_cache_size" - value = "{DBInstanceClassMemory*3/4096}" # 75% of memory + value = "{DBInstanceClassMemory*3/4096}" # 75% of memory } parameter { name = "work_mem" - value = "65536" # 64MB + value = "65536" # 64MB } parameter { name = "maintenance_work_mem" - value = "524288" # 512MB + value = "524288" # 512MB } # Logging @@ -88,7 +88,7 @@ resource "aws_db_parameter_group" "main" { parameter { name = "log_min_duration_statement" - value = "1000" # Log queries > 1s + value = "1000" # Log queries > 1s } # Connection @@ -191,7 +191,7 @@ resource "aws_cloudwatch_metric_alarm" "storage_low" { namespace = "AWS/RDS" period = 300 statistic = "Average" - threshold = 5368709120 # 5GB in bytes + threshold = 5368709120 # 5GB in bytes alarm_description = "RDS free storage space is low" dimensions = { diff --git a/infrastructure/terraform/modules/s3/main.tf b/infrastructure/terraform/modules/s3/main.tf index 4d305fa..0c6c0f4 100644 --- a/infrastructure/terraform/modules/s3/main.tf +++ b/infrastructure/terraform/modules/s3/main.tf @@ -151,8 +151,8 @@ resource "aws_s3_bucket_policy" "static" { Version = "2012-10-17" Statement = [ { - Sid = "AllowCloudFrontOAC" - Effect = "Allow" + Sid = "AllowCloudFrontOAC" + Effect = "Allow" Principal = { Service = "cloudfront.amazonaws.com" } diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf index c1de555..ef54685 100644 --- a/infrastructure/terraform/variables.tf +++ b/infrastructure/terraform/variables.tf @@ -36,10 +36,10 @@ variable "primary_region" { variable "regions" { description = "Map of regions with their configurations" type = map(object({ - enabled = bool - cidr_block = string - is_primary = bool - az_count = number + enabled = bool + cidr_block = string + is_primary = bool + az_count = number })) default = { "us-east-1" = { diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 434d10e..f875ca9 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -53,12 +53,15 @@ func main() { orgRepo := repository.NewOrganizationRepository(db) dbRepo := repository.NewDatabaseRepository(db) apiKeyRepo := repository.NewApiKeyRepository(db) + svcRepo := repository.NewServiceRepository(db) + engineRepo := repository.NewAvailableEngineRepository(db) // Initialize services jwtManager := auth.NewJWTManager(cfg) authService := services.NewAuthService(userRepo, orgRepo, jwtManager) dbService := services.NewDatabaseService(dbRepo, orgRepo, redisPool, cfg) userService := services.NewUserService(userRepo, apiKeyRepo) + svcService := services.NewServiceService(svcRepo, engineRepo, orgRepo, cfg) // Initialize handlers authHandler := handlers.NewAuthHandler(authService) @@ -66,13 +69,14 @@ func main() { redisProxyHandler := handlers.NewRedisProxyHandler(dbService, redisPool) healthHandler := handlers.NewHealthHandler() userHandler := handlers.NewUserHandler(userService) + serviceHandler := handlers.NewServiceHandler(svcService) // Initialize rate limiters rateLimiter := middleware.NewRateLimiter(cfg.RateLimitRequests, cfg.RateLimitWindow) authRateLimiter := middleware.NewRateLimiter(cfg.AuthRateLimitRequests, cfg.AuthRateLimitWindow) // Setup router - router := setupRouter(cfg, jwtManager, rateLimiter, authRateLimiter, authHandler, dbHandler, redisProxyHandler, healthHandler, userHandler) + router := setupRouter(cfg, jwtManager, rateLimiter, authRateLimiter, authHandler, dbHandler, redisProxyHandler, healthHandler, userHandler, serviceHandler) // Start server srv := &http.Server{ @@ -159,6 +163,7 @@ func setupRouter( redisProxyHandler *handlers.RedisProxyHandler, healthHandler *handlers.HealthHandler, userHandler *handlers.UserHandler, + serviceHandler *handlers.ServiceHandler, ) *gin.Engine { if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) @@ -229,6 +234,23 @@ func setupRouter( tokenAPI.POST("/databases/:id", redisProxyHandler.UpstashCommand) } + // Unified services endpoints (authenticated) + svcs := v1.Group("/services") + svcs.Use(middleware.AuthMiddleware(jwtManager)) + { + // Public info endpoints (no auth needed, but keeping under /services for consistency) + svcs.GET("/types", serviceHandler.GetServiceTypes) + svcs.GET("/engines", serviceHandler.GetAvailableEngines) + svcs.GET("/regions", serviceHandler.GetRegions) + + // Service CRUD + svcs.POST("", serviceHandler.Create) + svcs.GET("", serviceHandler.List) + svcs.GET("/:id", serviceHandler.Get) + svcs.DELETE("/:id", serviceHandler.Delete) + svcs.POST("/:id/reset-credentials", serviceHandler.ResetCredentials) + } + return router } diff --git a/services/api/go.mod b/services/api/go.mod index 5873ef9..28072d5 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -1,17 +1,17 @@ module github.com/lazycache-com/lazycache -go 1.23 +go 1.23.0 toolchain go1.24.4 require ( github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.5.0 - github.com/jackc/pgx/v5 v5.5.1 + github.com/jackc/pgx/v5 v5.5.4 github.com/redis/go-redis/v9 v9.3.1 github.com/rs/zerolog v1.31.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.35.0 ) require ( @@ -42,10 +42,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.6.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/services/api/go.sum b/services/api/go.sum index f6bd2d9..ba0ea40 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -39,8 +39,8 @@ github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -50,8 +50,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= -github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -107,20 +107,20 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc= golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= diff --git a/services/api/internal/handlers/common.go b/services/api/internal/handlers/common.go index eb0a3d3..e37e04b 100644 --- a/services/api/internal/handlers/common.go +++ b/services/api/internal/handlers/common.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/lazycache-com/lazycache/internal/errors" + "github.com/rs/zerolog/log" ) // ErrorResponse represents an error response @@ -18,6 +19,10 @@ type ErrorResponse struct { // respondError sends an error response func respondError(c *gin.Context, err error) { if appErr, ok := err.(*errors.AppError); ok { + // Log internal error for debugging + if appErr.Internal != nil { + log.Error().Err(appErr.Internal).Str("code", appErr.Code).Msg("Application error with internal cause") + } c.JSON(appErr.StatusCode, ErrorResponse{ Error: struct { Code string `json:"code"` @@ -30,6 +35,7 @@ func respondError(c *gin.Context, err error) { return } + log.Error().Err(err).Msg("Unhandled error") c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: struct { Code string `json:"code"` diff --git a/services/api/internal/handlers/redis_proxy.go b/services/api/internal/handlers/redis_proxy.go index ac0c9d9..04d7a67 100644 --- a/services/api/internal/handlers/redis_proxy.go +++ b/services/api/internal/handlers/redis_proxy.go @@ -242,9 +242,7 @@ func (h *RedisProxyHandler) TokenAuthMiddleware() gin.HandlerFunc { } // Remove Bearer prefix if present - if strings.HasPrefix(token, "Bearer ") { - token = strings.TrimPrefix(token, "Bearer ") - } + token = strings.TrimPrefix(token, "Bearer ") // Database tokens are 64 hex characters if len(token) != 64 { diff --git a/services/api/internal/handlers/services.go b/services/api/internal/handlers/services.go new file mode 100644 index 0000000..5e04817 --- /dev/null +++ b/services/api/internal/handlers/services.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/middleware" + "github.com/lazycache-com/lazycache/internal/models" + "github.com/lazycache-com/lazycache/internal/services" +) + +// ServiceHandler handles service management endpoints +type ServiceHandler struct { + svcService *services.ServiceService +} + +// NewServiceHandler creates a new service handler +func NewServiceHandler(svcService *services.ServiceService) *ServiceHandler { + return &ServiceHandler{svcService: svcService} +} + +// Create godoc +// @Summary Create a new service +// @Tags services +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body models.CreateServiceRequest true "Create service request" +// @Success 201 {object} models.ServiceResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 402 {object} ErrorResponse +// @Router /services [post] +func (h *ServiceHandler) Create(c *gin.Context) { + var req models.CreateServiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, errors.NewValidationError(err.Error())) + return + } + + // Validate engine is valid for service type + if !models.ValidateEngine(req.ServiceType, req.Engine) { + respondError(c, errors.NewValidationError("Invalid engine for service type")) + return + } + + orgID := middleware.GetOrganizationID(c) + + response, err := h.svcService.Create(c.Request.Context(), orgID, &req) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusCreated, response) +} + +// List godoc +// @Summary List all services +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param type query string false "Filter by service type (cache, database, vector, storage)" +// @Success 200 {object} map[string][]models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Router /services [get] +func (h *ServiceHandler) List(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + + // Optional filter by service type + serviceTypeStr := c.Query("type") + var serviceType *models.ServiceType + if serviceTypeStr != "" { + st := models.ServiceType(serviceTypeStr) + serviceType = &st + } + + svcs, err := h.svcService.List(c.Request.Context(), orgID, serviceType) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "services": svcs, + }) +} + +// Get godoc +// @Summary Get service by ID +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 200 {object} models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id} [get] +func (h *ServiceHandler) Get(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + svc, err := h.svcService.Get(c.Request.Context(), orgID, svcID) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, svc) +} + +// Delete godoc +// @Summary Delete a service +// @Tags services +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 204 "No Content" +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id} [delete] +func (h *ServiceHandler) Delete(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + if err := h.svcService.Delete(c.Request.Context(), orgID, svcID); err != nil { + respondError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// ResetCredentials godoc +// @Summary Reset service credentials +// @Tags services +// @Produce json +// @Security BearerAuth +// @Param id path string true "Service ID" +// @Success 200 {object} models.ServiceResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /services/{id}/reset-credentials [post] +func (h *ServiceHandler) ResetCredentials(c *gin.Context) { + svcID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, errors.NewValidationError("Invalid service ID")) + return + } + + orgID := middleware.GetOrganizationID(c) + + svc, err := h.svcService.ResetCredentials(c.Request.Context(), orgID, svcID) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, svc) +} + +// GetAvailableEngines godoc +// @Summary Get available engines +// @Tags services +// @Produce json +// @Param type query string false "Filter by service type (cache, database, vector, storage)" +// @Success 200 {object} map[string][]models.AvailableEngine +// @Router /services/engines [get] +func (h *ServiceHandler) GetAvailableEngines(c *gin.Context) { + serviceTypeStr := c.Query("type") + var serviceType *models.ServiceType + if serviceTypeStr != "" { + st := models.ServiceType(serviceTypeStr) + serviceType = &st + } + + engines, err := h.svcService.GetAvailableEngines(c.Request.Context(), serviceType) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "engines": engines, + }) +} + +// GetRegions godoc +// @Summary Get available regions for an engine +// @Tags services +// @Produce json +// @Param type query string true "Service type (cache, database, vector, storage)" +// @Param engine query string true "Engine (redis, valkey, postgresql, pgvector, s3, minio)" +// @Success 200 {object} map[string][]string +// @Router /services/regions [get] +func (h *ServiceHandler) GetRegions(c *gin.Context) { + serviceType := models.ServiceType(c.Query("type")) + engine := models.ServiceEngine(c.Query("engine")) + + if serviceType == "" || engine == "" { + respondError(c, errors.NewValidationError("Service type and engine are required")) + return + } + + regions, err := h.svcService.GetRegions(c.Request.Context(), serviceType, engine) + if err != nil { + respondError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "regions": regions, + }) +} + +// GetServiceTypes godoc +// @Summary Get available service types +// @Tags services +// @Produce json +// @Success 200 {object} map[string][]ServiceTypeInfo +// @Router /services/types [get] +func (h *ServiceHandler) GetServiceTypes(c *gin.Context) { + types := []map[string]interface{}{ + { + "type": models.ServiceTypeCache, + "name": "Cache", + "description": "In-memory key-value data store for caching and real-time data", + "engines": []string{"redis", "valkey"}, + }, + { + "type": models.ServiceTypeDatabase, + "name": "Database", + "description": "Relational database for structured data storage", + "engines": []string{"postgresql"}, + }, + { + "type": models.ServiceTypeVector, + "name": "Vector", + "description": "Vector database for AI/ML embeddings and similarity search", + "engines": []string{"pgvector"}, + }, + { + "type": models.ServiceTypeStorage, + "name": "Storage", + "description": "Object storage for files, backups, and media", + "engines": []string{"s3", "minio"}, + }, + } + + c.JSON(http.StatusOK, gin.H{ + "types": types, + }) +} diff --git a/services/api/internal/models/service.go b/services/api/internal/models/service.go new file mode 100644 index 0000000..b429fa1 --- /dev/null +++ b/services/api/internal/models/service.go @@ -0,0 +1,242 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// ServiceType represents the type of service +type ServiceType string + +const ( + ServiceTypeCache ServiceType = "cache" + ServiceTypeDatabase ServiceType = "database" + ServiceTypeVector ServiceType = "vector" + ServiceTypeStorage ServiceType = "storage" +) + +// ServiceEngine represents the specific engine implementation +type ServiceEngine string + +const ( + // Cache engines + EngineRedis ServiceEngine = "redis" + EngineValKey ServiceEngine = "valkey" + + // Database engines + EnginePostgreSQL ServiceEngine = "postgresql" + + // Vector engines + EnginePgVector ServiceEngine = "pgvector" + + // Storage engines + EngineS3 ServiceEngine = "s3" + EngineMinio ServiceEngine = "minio" +) + +// ServiceStatus represents the current status of a service +type ServiceStatus string + +const ( + ServiceStatusCreating ServiceStatus = "creating" + ServiceStatusActive ServiceStatus = "active" + ServiceStatusPaused ServiceStatus = "paused" + ServiceStatusDeleting ServiceStatus = "deleting" + ServiceStatusError ServiceStatus = "error" +) + +// Service represents a unified service instance +type Service struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + Region string `json:"region"` + + // Connection info + Endpoint string `json:"endpoint"` + Port int `json:"port"` + + // Authentication + Token string `json:"token,omitempty"` // REST API token + Credentials json.RawMessage `json:"-"` // Encrypted credentials (password, keys, etc.) + + // Configuration + Config json.RawMessage `json:"config,omitempty"` + + // Limits & Tier + Tier string `json:"tier"` + Limits json.RawMessage `json:"limits,omitempty"` + + // Status + Status ServiceStatus `json:"status"` + + // Cloud provider reference + ProviderResourceID string `json:"provider_resource_id,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + + // Timestamps + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ServiceCredentials holds the decrypted credentials for a service +type ServiceCredentials struct { + Password string `json:"password,omitempty"` + KeyPrefix string `json:"key_prefix,omitempty"` + Username string `json:"username,omitempty"` + AccessKey string `json:"access_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` +} + +// CacheConfig holds configuration for cache services (Redis/ValKey) +type CacheConfig struct { + MaxMemoryMB int `json:"max_memory_mb"` + MaxConnections int `json:"max_connections"` + MaxMemoryPolicy string `json:"maxmemory_policy,omitempty"` + AppendOnly bool `json:"appendonly,omitempty"` +} + +// DatabaseConfig holds configuration for database services (PostgreSQL) +type DatabaseConfig struct { + MaxConnections int `json:"max_connections"` + MaxStorageGB int `json:"max_storage_gb"` + SharedBuffers string `json:"shared_buffers,omitempty"` + DatabaseName string `json:"database_name,omitempty"` +} + +// VectorConfig holds configuration for vector services (pgvector) +type VectorConfig struct { + MaxVectors int `json:"max_vectors"` + MaxDimensions int `json:"max_dimensions"` + IVFFlatLists int `json:"ivfflat_lists,omitempty"` + HNSWM int `json:"hnsw_m,omitempty"` + HNSWEfConstruction int `json:"hnsw_ef_construction,omitempty"` +} + +// StorageConfig holds configuration for storage services (S3/MinIO) +type StorageConfig struct { + MaxStorageGB int `json:"max_storage_gb"` + MaxObjects int `json:"max_objects"` + Versioning bool `json:"versioning,omitempty"` + Encryption string `json:"encryption,omitempty"` + BucketName string `json:"bucket_name,omitempty"` +} + +// ServiceMetrics tracks service usage +type ServiceMetrics struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Date time.Time `json:"date"` + Hour int `json:"hour"` + RequestCount int64 `json:"requests_count"` + StorageBytes int64 `json:"storage_bytes"` + BandwidthIn int64 `json:"bandwidth_in"` + BandwidthOut int64 `json:"bandwidth_out"` + Metrics json.RawMessage `json:"metrics,omitempty"` // Engine-specific metrics + CreatedAt time.Time `json:"created_at"` +} + +// AvailableEngine represents an available engine option +type AvailableEngine struct { + ID uuid.UUID `json:"id"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + + // Display info + DisplayName string `json:"display_name"` + Description *string `json:"description,omitempty"` + IconURL *string `json:"icon_url,omitempty"` + + // Availability + IsActive bool `json:"is_active"` + Regions []string `json:"regions"` + + // Pricing + BasePriceMonthly float64 `json:"base_price_monthly"` + PricePerRequest float64 `json:"price_per_request"` + PricePerGBStorage float64 `json:"price_per_gb_storage"` + PricePerGBBandwidth float64 `json:"price_per_gb_bandwidth"` + + // Limits + FreeTierLimits json.RawMessage `json:"free_tier_limits,omitempty"` + + // Default config + DefaultConfig json.RawMessage `json:"default_config,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateServiceRequest represents a request to create a new service +type CreateServiceRequest struct { + Name string `json:"name" binding:"required,min=3,max=50"` + ServiceType ServiceType `json:"service_type" binding:"required"` + Engine ServiceEngine `json:"engine" binding:"required"` + Region string `json:"region" binding:"required"` + Config json.RawMessage `json:"config,omitempty"` +} + +// ServiceResponse is the API response for a service +type ServiceResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ServiceType ServiceType `json:"service_type"` + Engine ServiceEngine `json:"engine"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + Port int `json:"port"` + Status ServiceStatus `json:"status"` + Tier string `json:"tier"` + Config json.RawMessage `json:"config,omitempty"` + Limits json.RawMessage `json:"limits,omitempty"` + CreatedAt time.Time `json:"created_at"` + + // REST API access + RESTEndpoint string `json:"rest_endpoint,omitempty"` + Token string `json:"token,omitempty"` // Only shown on creation +} + +// GetEnginesByServiceType returns available engines for a service type +func GetEnginesByServiceType(st ServiceType) []ServiceEngine { + switch st { + case ServiceTypeCache: + return []ServiceEngine{EngineRedis, EngineValKey} + case ServiceTypeDatabase: + return []ServiceEngine{EnginePostgreSQL} + case ServiceTypeVector: + return []ServiceEngine{EnginePgVector} + case ServiceTypeStorage: + return []ServiceEngine{EngineS3, EngineMinio} + default: + return nil + } +} + +// ValidateEngine checks if the engine is valid for the service type +func ValidateEngine(st ServiceType, engine ServiceEngine) bool { + engines := GetEnginesByServiceType(st) + for _, e := range engines { + if e == engine { + return true + } + } + return false +} + +// GetDefaultPort returns the default port for an engine +func GetDefaultPort(engine ServiceEngine) int { + switch engine { + case EngineRedis, EngineValKey: + return 6379 + case EnginePostgreSQL, EnginePgVector: + return 5432 + case EngineS3, EngineMinio: + return 443 + default: + return 0 + } +} diff --git a/services/api/internal/repository/service.go b/services/api/internal/repository/service.go new file mode 100644 index 0000000..c92183c --- /dev/null +++ b/services/api/internal/repository/service.go @@ -0,0 +1,441 @@ +package repository + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/models" +) + +// ServiceRepository handles service operations +type ServiceRepository struct { + db *pgxpool.Pool +} + +// NewServiceRepository creates a new service repository +func NewServiceRepository(db *pgxpool.Pool) *ServiceRepository { + return &ServiceRepository{db: db} +} + +// Create creates a new service record +func (r *ServiceRepository) Create(ctx context.Context, service *models.Service) error { + query := ` + INSERT INTO services + (id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), NOW()) + ` + + _, err := r.db.Exec(ctx, query, + service.ID, + service.OrganizationID, + service.Name, + service.ServiceType, + service.Engine, + service.Region, + service.Endpoint, + service.Port, + service.Token, + service.Credentials, + service.Config, + service.Tier, + service.Limits, + service.Status, + service.ProviderResourceID, + service.ProviderMetadata, + ) + + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// GetByID retrieves a service by ID +func (r *ServiceRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services WHERE id = $1 AND status != 'deleting' + ` + + svc := &models.Service{} + err := r.db.QueryRow(ctx, query, id).Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Service") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + return svc, nil +} + +// GetByToken retrieves a service by REST API token +func (r *ServiceRepository) GetByToken(ctx context.Context, token string) (*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services WHERE token = $1 AND status = 'active' + ` + + svc := &models.Service{} + err := r.db.QueryRow(ctx, query, token).Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Service") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + return svc, nil +} + +// ListByOrganization lists all services for an organization +func (r *ServiceRepository) ListByOrganization(ctx context.Context, orgID uuid.UUID) ([]*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services + WHERE organization_id = $1 AND status != 'deleting' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(ctx, query, orgID) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var services []*models.Service + for rows.Next() { + svc := &models.Service{} + err := rows.Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + services = append(services, svc) + } + + return services, nil +} + +// ListByOrganizationAndType lists services by organization and type +func (r *ServiceRepository) ListByOrganizationAndType(ctx context.Context, orgID uuid.UUID, serviceType models.ServiceType) ([]*models.Service, error) { + query := ` + SELECT id, organization_id, name, service_type, engine, region, endpoint, port, token, credentials, config, tier, limits, status, provider_resource_id, provider_metadata, created_at, updated_at + FROM services + WHERE organization_id = $1 AND service_type = $2 AND status != 'deleting' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(ctx, query, orgID, serviceType) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var services []*models.Service + for rows.Next() { + svc := &models.Service{} + err := rows.Scan( + &svc.ID, + &svc.OrganizationID, + &svc.Name, + &svc.ServiceType, + &svc.Engine, + &svc.Region, + &svc.Endpoint, + &svc.Port, + &svc.Token, + &svc.Credentials, + &svc.Config, + &svc.Tier, + &svc.Limits, + &svc.Status, + &svc.ProviderResourceID, + &svc.ProviderMetadata, + &svc.CreatedAt, + &svc.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + services = append(services, svc) + } + + return services, nil +} + +// CountByOrganization counts services for an organization +func (r *ServiceRepository) CountByOrganization(ctx context.Context, orgID uuid.UUID) (int, error) { + query := `SELECT COUNT(*) FROM services WHERE organization_id = $1 AND status != 'deleting'` + + var count int + err := r.db.QueryRow(ctx, query, orgID).Scan(&count) + if err != nil { + return 0, errors.NewDatabaseError(err) + } + + return count, nil +} + +// CountByOrganizationAndType counts services by org and type +func (r *ServiceRepository) CountByOrganizationAndType(ctx context.Context, orgID uuid.UUID, serviceType models.ServiceType) (int, error) { + query := `SELECT COUNT(*) FROM services WHERE organization_id = $1 AND service_type = $2 AND status != 'deleting'` + + var count int + err := r.db.QueryRow(ctx, query, orgID, serviceType).Scan(&count) + if err != nil { + return 0, errors.NewDatabaseError(err) + } + + return count, nil +} + +// UpdateStatus updates service status +func (r *ServiceRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status models.ServiceStatus) error { + query := `UPDATE services SET status = $2, updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id, status) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// UpdateCredentials updates service credentials and token +func (r *ServiceRepository) UpdateCredentials(ctx context.Context, id uuid.UUID, credentials json.RawMessage, token string) error { + query := `UPDATE services SET credentials = $2, token = $3, updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id, credentials, token) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// Delete soft-deletes a service +func (r *ServiceRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `UPDATE services SET status = 'deleting', updated_at = NOW() WHERE id = $1` + + _, err := r.db.Exec(ctx, query, id) + if err != nil { + return errors.NewDatabaseError(err) + } + + return nil +} + +// AvailableEngineRepository handles available engine operations +type AvailableEngineRepository struct { + db *pgxpool.Pool +} + +// NewAvailableEngineRepository creates a new available engine repository +func NewAvailableEngineRepository(db *pgxpool.Pool) *AvailableEngineRepository { + return &AvailableEngineRepository{db: db} +} + +// List returns all active available engines +func (r *AvailableEngineRepository) List(ctx context.Context) ([]*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE is_active = true + ORDER BY service_type, display_name + ` + + rows, err := r.db.Query(ctx, query) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var engines []*models.AvailableEngine + for rows.Next() { + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := rows.Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + eng.Regions = regions + engines = append(engines, eng) + } + + return engines, nil +} + +// ListByServiceType returns available engines for a service type +func (r *AvailableEngineRepository) ListByServiceType(ctx context.Context, serviceType models.ServiceType) ([]*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE service_type = $1 AND is_active = true + ORDER BY display_name + ` + + rows, err := r.db.Query(ctx, query, serviceType) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + defer rows.Close() + + var engines []*models.AvailableEngine + for rows.Next() { + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := rows.Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + if err != nil { + return nil, errors.NewDatabaseError(err) + } + eng.Regions = regions + engines = append(engines, eng) + } + + return engines, nil +} + +// GetByEngine returns a specific engine configuration +func (r *AvailableEngineRepository) GetByEngine(ctx context.Context, serviceType models.ServiceType, engine models.ServiceEngine) (*models.AvailableEngine, error) { + query := ` + SELECT id, service_type, engine, display_name, description, icon_url, is_active, regions, + base_price_monthly, price_per_request, price_per_gb_storage, price_per_gb_bandwidth, + free_tier_limits, default_config, created_at, updated_at + FROM available_engines + WHERE service_type = $1 AND engine = $2 + ` + + eng := &models.AvailableEngine{} + var regions pgtype.FlatArray[string] + err := r.db.QueryRow(ctx, query, serviceType, engine).Scan( + &eng.ID, + &eng.ServiceType, + &eng.Engine, + &eng.DisplayName, + &eng.Description, + &eng.IconURL, + &eng.IsActive, + ®ions, + &eng.BasePriceMonthly, + &eng.PricePerRequest, + &eng.PricePerGBStorage, + &eng.PricePerGBBandwidth, + &eng.FreeTierLimits, + &eng.DefaultConfig, + &eng.CreatedAt, + &eng.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, errors.NewNotFoundError("Engine") + } + if err != nil { + return nil, errors.NewDatabaseError(err) + } + + eng.Regions = regions + return eng, nil +} diff --git a/services/api/internal/services/service_service.go b/services/api/internal/services/service_service.go new file mode 100644 index 0000000..533739c --- /dev/null +++ b/services/api/internal/services/service_service.go @@ -0,0 +1,350 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/lazycache-com/lazycache/internal/auth" + "github.com/lazycache-com/lazycache/internal/config" + "github.com/lazycache-com/lazycache/internal/errors" + "github.com/lazycache-com/lazycache/internal/models" + "github.com/lazycache-com/lazycache/internal/repository" +) + +// ServiceService handles unified service operations +type ServiceService struct { + svcRepo *repository.ServiceRepository + engineRepo *repository.AvailableEngineRepository + orgRepo *repository.OrganizationRepository + cfg *config.Config +} + +// NewServiceService creates a new service service +func NewServiceService( + svcRepo *repository.ServiceRepository, + engineRepo *repository.AvailableEngineRepository, + orgRepo *repository.OrganizationRepository, + cfg *config.Config, +) *ServiceService { + return &ServiceService{ + svcRepo: svcRepo, + engineRepo: engineRepo, + orgRepo: orgRepo, + cfg: cfg, + } +} + +// Create creates a new service instance +func (s *ServiceService) Create(ctx context.Context, orgID uuid.UUID, req *models.CreateServiceRequest) (*models.ServiceResponse, error) { + // Validate engine exists and is active + engine, err := s.engineRepo.GetByEngine(ctx, req.ServiceType, req.Engine) + if err != nil { + return nil, err + } + if !engine.IsActive { + return nil, errors.NewValidationError("Engine is not available") + } + + // Validate region is available for this engine + validRegion := false + for _, r := range engine.Regions { + if r == req.Region { + validRegion = true + break + } + } + if !validRegion { + return nil, errors.NewValidationError("Region not available for this engine") + } + + // Get organization and check plan limits + org, err := s.orgRepo.GetByID(ctx, orgID) + if err != nil { + return nil, err + } + + // Check service count limit based on plan + maxServices := s.getMaxServicesForPlan(org.Plan) + count, err := s.svcRepo.CountByOrganization(ctx, orgID) + if err != nil { + return nil, err + } + if maxServices > 0 && count >= maxServices { + return nil, errors.NewQuotaExceededError("Service limit reached for your plan") + } + + // Generate credentials based on service type + credentials, err := s.generateCredentials(req.ServiceType, req.Engine) + if err != nil { + return nil, errors.NewInternalError(err) + } + + credentialsJSON, err := json.Marshal(credentials) + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Generate REST API token + token, err := auth.GenerateDatabaseToken() + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Get default config and limits from engine + configJSON := engine.DefaultConfig + if req.Config != nil { + configJSON = req.Config + } + + // Create service record + svcID := uuid.New() + service := &models.Service{ + ID: svcID, + OrganizationID: orgID, + Name: req.Name, + ServiceType: req.ServiceType, + Engine: req.Engine, + Region: req.Region, + Endpoint: s.getEndpointForService(req.ServiceType, req.Engine, req.Region), + Port: models.GetDefaultPort(req.Engine), + Token: token, + Credentials: credentialsJSON, + Config: configJSON, + Tier: "free", + Limits: engine.FreeTierLimits, + Status: models.ServiceStatusCreating, + } + + if err := s.svcRepo.Create(ctx, service); err != nil { + return nil, err + } + + // For development/shared tier, mark as active immediately + if s.cfg.IsDevelopment() || service.Tier == "free" { + if err := s.svcRepo.UpdateStatus(ctx, svcID, models.ServiceStatusActive); err != nil { + return nil, err + } + service.Status = models.ServiceStatusActive + } + + return s.toResponse(service, credentials, true), nil +} + +// Get retrieves a service by ID +func (s *ServiceService) Get(ctx context.Context, orgID, svcID uuid.UUID) (*models.ServiceResponse, error) { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return nil, err + } + + // Check ownership + if service.OrganizationID != orgID { + return nil, errors.NewNotFoundError("Service") + } + + return s.toResponse(service, nil, false), nil +} + +// List lists all services for an organization +func (s *ServiceService) List(ctx context.Context, orgID uuid.UUID, serviceType *models.ServiceType) ([]*models.ServiceResponse, error) { + var services []*models.Service + var err error + + if serviceType != nil { + services, err = s.svcRepo.ListByOrganizationAndType(ctx, orgID, *serviceType) + } else { + services, err = s.svcRepo.ListByOrganization(ctx, orgID) + } + + if err != nil { + return nil, err + } + + responses := make([]*models.ServiceResponse, len(services)) + for i, svc := range services { + responses[i] = s.toResponse(svc, nil, false) + } + + return responses, nil +} + +// Delete deletes a service +func (s *ServiceService) Delete(ctx context.Context, orgID, svcID uuid.UUID) error { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return err + } + + // Check ownership + if service.OrganizationID != orgID { + return errors.NewNotFoundError("Service") + } + + // Mark as deleting (soft delete) + return s.svcRepo.Delete(ctx, svcID) +} + +// ResetCredentials generates new credentials for a service +func (s *ServiceService) ResetCredentials(ctx context.Context, orgID, svcID uuid.UUID) (*models.ServiceResponse, error) { + service, err := s.svcRepo.GetByID(ctx, svcID) + if err != nil { + return nil, err + } + + // Check ownership + if service.OrganizationID != orgID { + return nil, errors.NewNotFoundError("Service") + } + + // Generate new credentials + credentials, err := s.generateCredentials(service.ServiceType, service.Engine) + if err != nil { + return nil, errors.NewInternalError(err) + } + + credentialsJSON, err := json.Marshal(credentials) + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Generate new token + token, err := auth.GenerateDatabaseToken() + if err != nil { + return nil, errors.NewInternalError(err) + } + + // Update service + if err := s.svcRepo.UpdateCredentials(ctx, svcID, credentialsJSON, token); err != nil { + return nil, err + } + + service.Token = token + service.Credentials = credentialsJSON + + return s.toResponse(service, credentials, true), nil +} + +// GetByToken retrieves a service by REST API token +func (s *ServiceService) GetByToken(ctx context.Context, token string) (*models.Service, error) { + return s.svcRepo.GetByToken(ctx, token) +} + +// GetAvailableEngines returns available engines +func (s *ServiceService) GetAvailableEngines(ctx context.Context, serviceType *models.ServiceType) ([]*models.AvailableEngine, error) { + if serviceType != nil { + return s.engineRepo.ListByServiceType(ctx, *serviceType) + } + return s.engineRepo.List(ctx) +} + +// GetRegions returns available regions for an engine +func (s *ServiceService) GetRegions(ctx context.Context, serviceType models.ServiceType, engine models.ServiceEngine) ([]string, error) { + eng, err := s.engineRepo.GetByEngine(ctx, serviceType, engine) + if err != nil { + return nil, err + } + return eng.Regions, nil +} + +// generateCredentials creates appropriate credentials for the service type +func (s *ServiceService) generateCredentials(serviceType models.ServiceType, engine models.ServiceEngine) (*models.ServiceCredentials, error) { + creds := &models.ServiceCredentials{} + + switch serviceType { + case models.ServiceTypeCache: + // Redis/ValKey need password and key prefix + password, err := auth.GenerateRandomPassword(32) + if err != nil { + return nil, err + } + creds.Password = password + creds.KeyPrefix = fmt.Sprintf("%s:", uuid.New().String()[:8]) + + case models.ServiceTypeDatabase, models.ServiceTypeVector: + // PostgreSQL/pgvector need username and password + password, err := auth.GenerateRandomPassword(32) + if err != nil { + return nil, err + } + creds.Username = fmt.Sprintf("user_%s", uuid.New().String()[:8]) + creds.Password = password + + case models.ServiceTypeStorage: + // S3/MinIO need access key and secret key + accessKey, err := auth.GenerateRandomPassword(20) + if err != nil { + return nil, err + } + secretKey, err := auth.GenerateRandomPassword(40) + if err != nil { + return nil, err + } + creds.AccessKey = accessKey + creds.SecretKey = secretKey + } + + return creds, nil +} + +// getEndpointForService returns the appropriate endpoint for a service +func (s *ServiceService) getEndpointForService(serviceType models.ServiceType, engine models.ServiceEngine, region string) string { + if s.cfg.IsDevelopment() { + switch serviceType { + case models.ServiceTypeCache: + return "localhost" + case models.ServiceTypeDatabase, models.ServiceTypeVector: + return "localhost" + case models.ServiceTypeStorage: + return "localhost" + default: + return "localhost" + } + } + + // Production endpoints + prefix := string(engine) + return fmt.Sprintf("%s-%s.lazycache.com", prefix, region) +} + +// getMaxServicesForPlan returns the maximum services allowed for a plan +func (s *ServiceService) getMaxServicesForPlan(plan string) int { + switch plan { + case "free": + return 3 + case "pro": + return 10 + case "team": + return 50 + case "business": + return -1 // unlimited + default: + return 3 + } +} + +// toResponse converts a service model to API response +func (s *ServiceService) toResponse(svc *models.Service, creds *models.ServiceCredentials, includeToken bool) *models.ServiceResponse { + resp := &models.ServiceResponse{ + ID: svc.ID, + Name: svc.Name, + ServiceType: svc.ServiceType, + Engine: svc.Engine, + Region: svc.Region, + Endpoint: svc.Endpoint, + Port: svc.Port, + Status: svc.Status, + Tier: svc.Tier, + Config: svc.Config, + Limits: svc.Limits, + CreatedAt: svc.CreatedAt, + RESTEndpoint: fmt.Sprintf("https://api.lazycache.com/v1/services/%s", svc.ID.String()), + } + + if includeToken { + resp.Token = svc.Token + } + + return resp +} diff --git a/services/api/migrations/002_multi_service_support.down.sql b/services/api/migrations/002_multi_service_support.down.sql new file mode 100644 index 0000000..3439ba2 --- /dev/null +++ b/services/api/migrations/002_multi_service_support.down.sql @@ -0,0 +1,17 @@ +-- ============================================================================= +-- Multi-Service Support Migration - ROLLBACK +-- ============================================================================= + +-- Drop triggers +DROP TRIGGER IF EXISTS update_services_updated_at ON services; +DROP TRIGGER IF EXISTS update_available_engines_updated_at ON available_engines; + +-- Drop tables +DROP TABLE IF EXISTS service_metrics; +DROP TABLE IF EXISTS services; +DROP TABLE IF EXISTS available_engines; + +-- Drop types +DROP TYPE IF EXISTS service_status; +DROP TYPE IF EXISTS service_engine; +DROP TYPE IF EXISTS service_type; diff --git a/services/api/migrations/002_multi_service_support.up.sql b/services/api/migrations/002_multi_service_support.up.sql new file mode 100644 index 0000000..969e052 --- /dev/null +++ b/services/api/migrations/002_multi_service_support.up.sql @@ -0,0 +1,236 @@ +-- ============================================================================= +-- Multi-Service Support Migration +-- ============================================================================= +-- Adds support for multiple service types: Cache, Database, Vector, Storage +-- Each service type can have multiple engine options (Redis/ValKey, PostgreSQL, etc.) +-- ============================================================================= + +-- ============================================================================= +-- Service Types Enum +-- ============================================================================= +CREATE TYPE service_type AS ENUM ('cache', 'database', 'vector', 'storage'); +CREATE TYPE service_engine AS ENUM ( + -- Cache engines + 'redis', 'valkey', + -- Database engines + 'postgresql', + -- Vector engines + 'pgvector', + -- Storage engines + 's3', 'minio' +); +CREATE TYPE service_status AS ENUM ('creating', 'active', 'paused', 'deleting', 'error'); + +-- ============================================================================= +-- Unified Services Table +-- ============================================================================= +CREATE TABLE services ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- Service identification + name VARCHAR(255) NOT NULL, + service_type service_type NOT NULL, + engine service_engine NOT NULL, + + -- Location + region VARCHAR(50) NOT NULL, + + -- Connection info + endpoint VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + + -- Authentication + token VARCHAR(255) NOT NULL UNIQUE, + credentials JSONB NOT NULL DEFAULT '{}', -- Encrypted credentials storage + + -- Configuration (engine-specific) + config JSONB NOT NULL DEFAULT '{}', + + -- Limits + tier VARCHAR(50) NOT NULL DEFAULT 'free', + limits JSONB NOT NULL DEFAULT '{}', + + -- Status + status service_status NOT NULL DEFAULT 'creating', + + -- Cloud provider reference (for AWS/GCP/Azure managed services) + provider_resource_id VARCHAR(255), + provider_metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Indexes for services +CREATE INDEX idx_services_organization_id ON services(organization_id); +CREATE INDEX idx_services_token ON services(token); +CREATE INDEX idx_services_service_type ON services(service_type); +CREATE INDEX idx_services_engine ON services(engine); +CREATE INDEX idx_services_status ON services(status); +CREATE INDEX idx_services_region ON services(region); + +-- ============================================================================= +-- Service Usage Metrics (Updated) +-- ============================================================================= +CREATE TABLE service_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + + -- Time bucket + date DATE NOT NULL, + hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23), + + -- Common metrics + requests_count BIGINT NOT NULL DEFAULT 0, + storage_bytes BIGINT NOT NULL DEFAULT 0, + bandwidth_in BIGINT NOT NULL DEFAULT 0, + bandwidth_out BIGINT NOT NULL DEFAULT 0, + + -- Engine-specific metrics + metrics JSONB NOT NULL DEFAULT '{}', + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(service_id, date, hour) +); + +CREATE INDEX idx_service_metrics_service_id ON service_metrics(service_id); +CREATE INDEX idx_service_metrics_date ON service_metrics(date); + +-- ============================================================================= +-- Available Engines Configuration +-- ============================================================================= +CREATE TABLE available_engines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + service_type service_type NOT NULL, + engine service_engine NOT NULL, + + -- Display info + display_name VARCHAR(100) NOT NULL, + description TEXT, + icon_url VARCHAR(500), + + -- Availability + is_active BOOLEAN NOT NULL DEFAULT true, + regions TEXT[] NOT NULL DEFAULT '{}', -- Available regions + + -- Pricing (per unit) + base_price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0, + price_per_request DECIMAL(10,6) NOT NULL DEFAULT 0, + price_per_gb_storage DECIMAL(10,4) NOT NULL DEFAULT 0, + price_per_gb_bandwidth DECIMAL(10,4) NOT NULL DEFAULT 0, + + -- Limits for free tier + free_tier_limits JSONB NOT NULL DEFAULT '{}', + + -- Default configuration + default_config JSONB NOT NULL DEFAULT '{}', + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + UNIQUE(service_type, engine) +); + +-- ============================================================================= +-- Seed Available Engines +-- ============================================================================= +INSERT INTO available_engines (service_type, engine, display_name, description, regions, free_tier_limits, default_config) VALUES +-- Cache engines +('cache', 'redis', 'Redis', 'The original in-memory data store. Fast, reliable, feature-rich.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_memory_mb": 256, "max_connections": 100, "max_commands_per_day": 500000}', + '{"maxmemory_policy": "volatile-lru", "appendonly": true}'), + +('cache', 'valkey', 'ValKey', 'Open source Redis fork by Linux Foundation. BSD licensed, community-driven.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_memory_mb": 256, "max_connections": 100, "max_commands_per_day": 500000}', + '{"maxmemory_policy": "volatile-lru", "appendonly": true}'), + +-- Database engines +('database', 'postgresql', 'PostgreSQL', 'The world''s most advanced open source relational database.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 1, "max_connections": 20, "max_queries_per_day": 100000}', + '{"max_connections": 100, "shared_buffers": "128MB"}'), + +-- Vector engines +('vector', 'pgvector', 'pgvector', 'Open-source vector similarity search for PostgreSQL.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 1, "max_vectors": 100000, "max_dimensions": 1536}', + '{"ivfflat_lists": 100, "hnsw_m": 16, "hnsw_ef_construction": 64}'), + +-- Storage engines +('storage', 's3', 'S3 Compatible', 'Amazon S3 compatible object storage.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 5, "max_objects": 10000, "max_bandwidth_gb": 10}', + '{"versioning": false, "encryption": "AES256"}'), + +('storage', 'minio', 'MinIO', 'High-performance, S3 compatible object storage.', + ARRAY['us-east-1', 'eu-central-1', 'ap-southeast-1'], + '{"max_storage_gb": 5, "max_objects": 10000, "max_bandwidth_gb": 10}', + '{"versioning": false, "encryption": "AES256"}'); + +-- ============================================================================= +-- Migrate existing redis_instances to services +-- ============================================================================= +INSERT INTO services ( + id, organization_id, name, service_type, engine, region, + endpoint, port, token, credentials, config, tier, limits, status, + provider_resource_id, created_at, updated_at +) +SELECT + id, + organization_id, + name, + 'cache'::service_type, + 'redis'::service_engine, + region, + endpoint, + port, + token, + jsonb_build_object('password', password, 'key_prefix', key_prefix), + jsonb_build_object('max_memory_mb', max_memory_mb, 'max_connections', max_connections), + tier, + jsonb_build_object('max_memory_mb', max_memory_mb, 'max_connections', max_connections), + status::text::service_status, + elasticache_cluster_id, + created_at, + updated_at +FROM redis_instances; + +-- Migrate usage_metrics to service_metrics +INSERT INTO service_metrics ( + id, service_id, date, hour, requests_count, storage_bytes, + bandwidth_in, bandwidth_out, metrics, created_at +) +SELECT + id, + database_id, + date, + hour, + commands_count, + storage_bytes, + bandwidth_in, + bandwidth_out, + jsonb_build_object('peak_connections', peak_connections), + created_at +FROM usage_metrics; + +-- ============================================================================= +-- Update triggers +-- ============================================================================= +CREATE TRIGGER update_services_updated_at + BEFORE UPDATE ON services + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_available_engines_updated_at + BEFORE UPDATE ON available_engines + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================= +-- Note: We keep redis_instances and usage_metrics for backward compatibility +-- They can be dropped in a future migration after API is updated +-- ============================================================================= diff --git a/services/dashboard/.eslintrc.cjs b/services/dashboard/.eslintrc.cjs new file mode 100644 index 0000000..41721dc --- /dev/null +++ b/services/dashboard/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +} diff --git a/services/dashboard/index.html b/services/dashboard/index.html index e8f195c..230909e 100644 --- a/services/dashboard/index.html +++ b/services/dashboard/index.html @@ -2,9 +2,35 @@
- + -- Here's what's happening with your Redis databases today. + Here's what's happening with your data services today.
- Total Databases + Total Services
- {databases.length} + {services.length}
Active
- {activeDatabases.length} + {activeServices.length}
By Type
+Create Database
+Create Service
- Spin up a new Redis instance + Spin up a new data service
View Databases
+View Services
- Manage your existing databases + Manage your existing services
No databases yet
+No services yet
- Create your first database + Create your first service- {db.name} -
-{db.region}
-+ {service.name} +
+ + {service.engine} + +{service.region}
++ Cache, Database, Vector Search, and Storage — all in one place. + Built for developers who ship fast. +
+ + {/* Terminal */} ++ Stop juggling multiple services. LazyCache unifies your entire data stack + with a consistent API and developer experience. +
+{service.description}
++ Clean, intuitive APIs that just work. Native SDKs for every major language, + comprehensive documentation, and a CLI that makes deployment a breeze. +
+ + {/* Tabs */} +
+
+ {codeExamples[activeTab]}
+
+
+ + Everything you need to build modern applications, with none of the infrastructure headaches. +
+{feature.description}
++ Start free, scale as you grow. No hidden fees, no surprises. +
+{plan.description}
++ LazyCache is MIT licensed. Deploy anywhere — your infrastructure, your rules. + Self-host for free or use our managed cloud. +
+ +Service not found
+{svc.region} • {svc.service_type}
+Tier
++ {svc.tier} +
+Service Type
++ {svc.service_type} +
+Engine
++ {svc.engine} +
+{key.replace(/_/g, ' ')}
+{String(value)}
+
+ {commandResult}
+
+ Example commands:
++ Once you delete a service, there is no going back. Please be certain. +
+ ++ Are you sure you want to delete "{svc.name}"? This action cannot + be undone. +
++ This token will only be shown once. Make sure to save it securely. +
++ Manage your data services - Cache, Database, Vector, Storage +
+Loading services...
++ Get started by creating your first service +
+ +{service.region}
+ ++ Important: Save these credentials now. The token + won't be shown again. +
+