diff --git a/internal/api/domains_test.go b/internal/api/domains_test.go new file mode 100644 index 0000000..72bfbe8 --- /dev/null +++ b/internal/api/domains_test.go @@ -0,0 +1,384 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/pkg/models" +) + +func setupDomainsTestServer(t *testing.T) (*Server, string, func()) { + gin.SetMode(gin.TestMode) + + tmpDir, err := os.MkdirTemp("", "domains-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + manager := docker.NewManager(tmpDir) + + server := &Server{ + manager: manager, + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + return server, tmpDir, cleanup +} + +func createTestDeployment(t *testing.T, basePath, name string, metadata *models.ServiceMetadata) { + deployDir := filepath.Join(basePath, name) + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatalf("Failed to create deployment dir: %v", err) + } + + composeContent := `name: ` + name + ` +services: + web: + image: nginx:latest +` + if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte(composeContent), 0644); err != nil { + t.Fatalf("Failed to write compose file: %v", err) + } + + if metadata != nil { + metadataBytes, err := yaml.Marshal(metadata) + if err != nil { + t.Fatalf("Failed to marshal metadata: %v", err) + } + if err := os.WriteFile(filepath.Join(deployDir, "service.yml"), metadataBytes, 0644); err != nil { + t.Fatalf("Failed to write service.yml: %v", err) + } + } +} + +func TestListDomains(t *testing.T) { + server, tmpDir, cleanup := setupDomainsTestServer(t) + defer cleanup() + + t.Run("returns empty array when no domains", func(t *testing.T) { + createTestDeployment(t, tmpDir, "no-domains", &models.ServiceMetadata{ + Name: "no-domains", + Type: "web", + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: "no-domains"}} + + server.listDomains(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response map[string][]models.DomainConfig + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if len(response["domains"]) != 0 { + t.Errorf("expected 0 domains, got %d", len(response["domains"])) + } + }) + + t.Run("returns legacy domain with default ID", func(t *testing.T) { + createTestDeployment(t, tmpDir, "legacy-domain", &models.ServiceMetadata{ + Name: "legacy-domain", + Type: "web", + Networking: models.NetworkingConfig{ + Expose: true, + Domain: "legacy.example.com", + ContainerPort: 80, + }, + SSL: models.SSLConfig{ + Enabled: true, + AutoCert: true, + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: "legacy-domain"}} + + server.listDomains(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response map[string][]models.DomainConfig + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if len(response["domains"]) != 1 { + t.Fatalf("expected 1 domain, got %d", len(response["domains"])) + } + + domain := response["domains"][0] + if domain.ID != "default" { + t.Errorf("expected ID 'default', got '%s'", domain.ID) + } + if domain.Domain != "legacy.example.com" { + t.Errorf("expected domain 'legacy.example.com', got '%s'", domain.Domain) + } + }) + + t.Run("returns explicit domains", func(t *testing.T) { + createTestDeployment(t, tmpDir, "explicit-domains", &models.ServiceMetadata{ + Name: "explicit-domains", + Type: "web", + Domains: []models.DomainConfig{ + { + ID: "domain-1", + Service: "web", + ContainerPort: 80, + Domain: "app.example.com", + }, + { + ID: "domain-2", + Service: "api", + ContainerPort: 8080, + Domain: "api.example.com", + }, + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: "explicit-domains"}} + + server.listDomains(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response map[string][]models.DomainConfig + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if len(response["domains"]) != 2 { + t.Fatalf("expected 2 domains, got %d", len(response["domains"])) + } + }) +} + +func TestDeleteDomain(t *testing.T) { + server, tmpDir, cleanup := setupDomainsTestServer(t) + defer cleanup() + + t.Run("deletes legacy domain with default ID", func(t *testing.T) { + createTestDeployment(t, tmpDir, "delete-legacy", &models.ServiceMetadata{ + Name: "delete-legacy", + Type: "web", + Networking: models.NetworkingConfig{ + Expose: true, + Domain: "legacy.example.com", + ContainerPort: 80, + }, + SSL: models.SSLConfig{ + Enabled: true, + AutoCert: true, + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "name", Value: "delete-legacy"}, + {Key: "domainId", Value: "default"}, + } + + server.deleteDomain(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the networking config was cleared + metadataPath := filepath.Join(tmpDir, "delete-legacy", "service.yml") + data, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("Failed to read metadata: %v", err) + } + var metadata models.ServiceMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + t.Fatalf("Failed to unmarshal metadata: %v", err) + } + + if metadata.Networking.Expose { + t.Error("expected Networking.Expose to be false") + } + if metadata.Networking.Domain != "" { + t.Errorf("expected Networking.Domain to be empty, got '%s'", metadata.Networking.Domain) + } + }) + + t.Run("deletes explicit domain by ID", func(t *testing.T) { + createTestDeployment(t, tmpDir, "delete-explicit", &models.ServiceMetadata{ + Name: "delete-explicit", + Type: "web", + Domains: []models.DomainConfig{ + { + ID: "domain-1", + Service: "web", + ContainerPort: 80, + Domain: "app.example.com", + }, + { + ID: "domain-2", + Service: "api", + ContainerPort: 8080, + Domain: "api.example.com", + }, + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "name", Value: "delete-explicit"}, + {Key: "domainId", Value: "domain-1"}, + } + + server.deleteDomain(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify only domain-2 remains + metadataPath := filepath.Join(tmpDir, "delete-explicit", "service.yml") + data, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("Failed to read metadata: %v", err) + } + var metadata models.ServiceMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + t.Fatalf("Failed to unmarshal metadata: %v", err) + } + + if len(metadata.Domains) != 1 { + t.Fatalf("expected 1 domain remaining, got %d", len(metadata.Domains)) + } + if metadata.Domains[0].ID != "domain-2" { + t.Errorf("expected remaining domain ID 'domain-2', got '%s'", metadata.Domains[0].ID) + } + }) + + t.Run("returns 404 for non-existent domain", func(t *testing.T) { + createTestDeployment(t, tmpDir, "no-such-domain", &models.ServiceMetadata{ + Name: "no-such-domain", + Type: "web", + Domains: []models.DomainConfig{ + { + ID: "domain-1", + Domain: "app.example.com", + }, + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "name", Value: "no-such-domain"}, + {Key: "domainId", Value: "non-existent"}, + } + + server.deleteDomain(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } + }) + + t.Run("returns 404 for default ID when no legacy domain", func(t *testing.T) { + createTestDeployment(t, tmpDir, "no-legacy", &models.ServiceMetadata{ + Name: "no-legacy", + Type: "web", + Networking: models.NetworkingConfig{ + Expose: false, + Domain: "", + }, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "name", Value: "no-legacy"}, + {Key: "domainId", Value: "default"}, + } + + server.deleteDomain(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } + }) +} + +func TestAddDomain(t *testing.T) { + server, tmpDir, cleanup := setupDomainsTestServer(t) + defer cleanup() + + t.Run("adds domain to deployment", func(t *testing.T) { + createTestDeployment(t, tmpDir, "add-domain", &models.ServiceMetadata{ + Name: "add-domain", + Type: "web", + }) + + newDomain := models.DomainConfig{ + Service: "web", + ContainerPort: 80, + Domain: "new.example.com", + SSL: models.SSLConfig{ + Enabled: true, + AutoCert: true, + }, + } + body, _ := json.Marshal(newDomain) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: "add-domain"}} + c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + server.addDomain(c) + + // Check metadata was saved (proxy setup may fail without orchestrator) + metadataPath := filepath.Join(tmpDir, "add-domain", "service.yml") + data, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("Failed to read metadata: %v", err) + } + var metadata models.ServiceMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + t.Fatalf("Failed to unmarshal metadata: %v", err) + } + + if len(metadata.Domains) != 1 { + t.Fatalf("expected 1 domain, got %d", len(metadata.Domains)) + } + if metadata.Domains[0].Domain != "new.example.com" { + t.Errorf("expected domain 'new.example.com', got '%s'", metadata.Domains[0].Domain) + } + if metadata.Domains[0].ID == "" { + t.Error("expected domain ID to be generated") + } + }) +} diff --git a/internal/api/multi_database_test.go b/internal/api/multi_database_test.go new file mode 100644 index 0000000..98a8daa --- /dev/null +++ b/internal/api/multi_database_test.go @@ -0,0 +1,393 @@ +package api + +import ( + "testing" + + "github.com/flatrun/agent/pkg/models" +) + +func TestGenerateDatabaseEnvVars(t *testing.T) { + s := &Server{} + + tests := []struct { + name string + prefix string + host string + port int + dbName string + username string + password string + dbType string + includeLegacy bool + wantKeys []string + wantLegacy bool + }{ + { + name: "mysql with prefix and legacy", + prefix: "PRIMARY", + host: "localhost", + port: 3306, + dbName: "myapp_db", + username: "myapp_user", + password: "secret123", + dbType: "mysql", + includeLegacy: true, + wantKeys: []string{"PRIMARY_HOST", "PRIMARY_PORT", "PRIMARY_DATABASE", "PRIMARY_USERNAME", "PRIMARY_PASSWORD", "PRIMARY_URL"}, + wantLegacy: true, + }, + { + name: "postgres without legacy", + prefix: "ANALYTICS", + host: "db.example.com", + port: 5432, + dbName: "analytics", + username: "analyst", + password: "pass123", + dbType: "postgres", + includeLegacy: false, + wantKeys: []string{"ANALYTICS_HOST", "ANALYTICS_PORT", "ANALYTICS_DATABASE", "ANALYTICS_USERNAME", "ANALYTICS_PASSWORD", "ANALYTICS_URL"}, + wantLegacy: false, + }, + { + name: "redis generates correct URL", + prefix: "CACHE", + host: "redis.local", + port: 6379, + dbName: "", + username: "", + password: "redispass", + dbType: "redis", + includeLegacy: false, + wantKeys: []string{"CACHE_HOST", "CACHE_PORT", "CACHE_URL"}, + wantLegacy: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envVars := s.generateDatabaseEnvVars( + tt.prefix, + tt.host, + tt.port, + tt.dbName, + tt.username, + tt.password, + tt.dbType, + tt.includeLegacy, + ) + + envMap := make(map[string]string) + for _, ev := range envVars { + envMap[ev.Key] = ev.Value + } + + for _, key := range tt.wantKeys { + if _, ok := envMap[key]; !ok { + t.Errorf("expected key %s not found in env vars", key) + } + } + + if tt.wantLegacy { + legacyKeys := []string{"DB_HOST", "DB_PORT", "DB_DATABASE", "DB_USERNAME", "DB_PASSWORD", "DATABASE_URL"} + for _, key := range legacyKeys { + if _, ok := envMap[key]; !ok { + t.Errorf("expected legacy key %s not found in env vars", key) + } + } + } + + if !tt.wantLegacy { + if _, ok := envMap["DB_HOST"]; ok { + t.Error("legacy key DB_HOST should not be present") + } + } + + if tt.prefix+"_HOST" != "" { + if envMap[tt.prefix+"_HOST"] != tt.host { + t.Errorf("%s_HOST = %s, want %s", tt.prefix, envMap[tt.prefix+"_HOST"], tt.host) + } + } + }) + } +} + +func TestDatabaseConfigRequest_Validate(t *testing.T) { + tests := []struct { + name string + req DatabaseConfigRequest + wantErr bool + errMsg string + }{ + { + name: "valid shared database config", + req: DatabaseConfigRequest{ + Alias: "primary", + Type: "mysql", + Mode: "shared", + }, + wantErr: false, + }, + { + name: "valid external database config", + req: DatabaseConfigRequest{ + Alias: "analytics", + Type: "postgres", + Mode: "external", + ExternalHost: "db.example.com", + ExternalPort: 5432, + DatabaseName: "analytics_db", + Username: "analyst", + Password: "secret", + }, + wantErr: false, + }, + { + name: "valid existing container config", + req: DatabaseConfigRequest{ + Alias: "cache", + Type: "redis", + Mode: "existing", + ExistingContainer: "redis-server", + }, + wantErr: false, + }, + { + name: "valid create mode config", + req: DatabaseConfigRequest{ + Alias: "newdb", + Type: "mariadb", + Mode: "create", + }, + wantErr: false, + }, + { + name: "invalid database type", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "oracle", + Mode: "shared", + }, + wantErr: true, + errMsg: "invalid database type", + }, + { + name: "invalid mode", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "mysql", + Mode: "invalid", + }, + wantErr: true, + errMsg: "invalid database mode", + }, + { + name: "existing mode missing container", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "mysql", + Mode: "existing", + }, + wantErr: true, + errMsg: "existing_container is required", + }, + { + name: "external mode missing host", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "postgres", + Mode: "external", + ExternalPort: 5432, + }, + wantErr: true, + errMsg: "external_host is required", + }, + { + name: "external mode missing port", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "postgres", + Mode: "external", + ExternalHost: "db.example.com", + }, + wantErr: true, + errMsg: "external_port must be a positive integer", + }, + { + name: "external mode with zero port", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "postgres", + Mode: "external", + ExternalHost: "db.example.com", + ExternalPort: 0, + }, + wantErr: true, + errMsg: "external_port must be a positive integer", + }, + { + name: "external mode with negative port", + req: DatabaseConfigRequest{ + Alias: "test", + Type: "postgres", + Mode: "external", + ExternalHost: "db.example.com", + ExternalPort: -1, + }, + wantErr: true, + errMsg: "external_port must be a positive integer", + }, + { + name: "valid mongodb config", + req: DatabaseConfigRequest{ + Alias: "mongo", + Type: "mongodb", + Mode: "shared", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if tt.wantErr { + if err == nil { + t.Errorf("Validate() expected error containing %q, got nil", tt.errMsg) + return + } + if tt.errMsg != "" && !containsSubstring(err.Error(), tt.errMsg) { + t.Errorf("Validate() error = %q, want error containing %q", err.Error(), tt.errMsg) + } + } else { + if err != nil { + t.Errorf("Validate() unexpected error: %v", err) + } + } + }) + } +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestMultipleDatabaseConfigs(t *testing.T) { + metadata := &models.ServiceMetadata{ + Name: "myapp", + Databases: []models.DatabaseConfig{ + { + ID: "myapp-primary", + Alias: "primary", + Type: "mysql", + Mode: "shared", + Host: "mysql-shared", + Port: 3306, + DatabaseName: "myapp_primary_db", + Username: "myapp_primary_user", + EnvPrefix: "PRIMARY", + IsShared: true, + }, + { + ID: "myapp-cache", + Alias: "cache", + Type: "redis", + Mode: "existing", + Container: "redis-server", + Host: "redis-server", + Port: 6379, + EnvPrefix: "CACHE", + IsShared: false, + }, + { + ID: "myapp-analytics", + Alias: "analytics", + Type: "postgres", + Mode: "external", + Host: "analytics.db.example.com", + Port: 5432, + DatabaseName: "analytics", + Username: "analyst", + EnvPrefix: "ANALYTICS", + IsShared: false, + }, + }, + } + + if !metadata.HasMultipleDatabases() { + t.Error("HasMultipleDatabases() = false, want true") + } + + databases := metadata.GetDatabases() + if len(databases) != 3 { + t.Errorf("GetDatabases() returned %d, want 3", len(databases)) + } + + primary := metadata.GetPrimaryDatabase() + if primary == nil { + t.Fatal("GetPrimaryDatabase() returned nil") + } + if primary.Alias != "primary" { + t.Errorf("GetPrimaryDatabase().Alias = %s, want primary", primary.Alias) + } + + sharedCount := 0 + for _, db := range databases { + if db.IsShared { + sharedCount++ + } + } + if sharedCount != 1 { + t.Errorf("shared database count = %d, want 1", sharedCount) + } +} + +func TestDatabaseEnvPrefixGeneration(t *testing.T) { + tests := []struct { + alias string + envPrefix string + wantPrefix string + }{ + { + alias: "primary", + envPrefix: "", + wantPrefix: "PRIMARY", + }, + { + alias: "cache", + envPrefix: "REDIS", + wantPrefix: "REDIS", + }, + { + alias: "analytics-db", + envPrefix: "", + wantPrefix: "ANALYTICS-DB", + }, + } + + for _, tt := range tests { + t.Run(tt.alias, func(t *testing.T) { + prefix := tt.envPrefix + if prefix == "" { + prefix = tt.alias + } + + upperPrefix := "" + for _, c := range prefix { + if c >= 'a' && c <= 'z' { + upperPrefix += string(c - 32) + } else { + upperPrefix += string(c) + } + } + + if upperPrefix != tt.wantPrefix { + t.Errorf("prefix = %s, want %s", upperPrefix, tt.wantPrefix) + } + }) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 67b7916..a4011e2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -40,6 +40,7 @@ import ( "github.com/flatrun/agent/pkg/version" "github.com/flatrun/agent/templates" "github.com/gin-gonic/gin" + "github.com/google/uuid" "gopkg.in/yaml.v3" ) @@ -264,6 +265,12 @@ func (s *Server) setupRoutes() { protected.POST("/proxy/sync", s.authMiddleware.RequirePermission(auth.PermCertificatesWrite), s.syncAllProxies) protected.POST("/deployments/:name/ssl/disable", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.disableSSL) + // Domain endpoints + protected.GET("/deployments/:name/domains", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.listDomains) + protected.POST("/deployments/:name/domains", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.addDomain) + protected.PUT("/deployments/:name/domains/:domainId", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDomain) + protected.DELETE("/deployments/:name/domains/:domainId", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.deleteDomain) + // Settings endpoints protected.GET("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.getSettings) protected.PUT("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSettings) @@ -570,6 +577,53 @@ func (s *Server) getDeployment(c *gin.Context) { }) } +type DatabaseConfigRequest struct { + Alias string `json:"alias"` + Type string `json:"type"` + Mode string `json:"mode"` + Service string `json:"service,omitempty"` + ExistingContainer string `json:"existing_container,omitempty"` + ExternalHost string `json:"external_host,omitempty"` + ExternalPort int `json:"external_port,omitempty"` + DatabaseName string `json:"database_name,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + EnvPrefix string `json:"env_prefix,omitempty"` +} + +func (d *DatabaseConfigRequest) Validate() error { + validTypes := map[string]bool{ + "mysql": true, "postgres": true, "mariadb": true, + "mongodb": true, "redis": true, + } + if !validTypes[d.Type] { + return fmt.Errorf("invalid database type: %s (must be mysql, postgres, mariadb, mongodb, or redis)", d.Type) + } + + validModes := map[string]bool{ + "shared": true, "create": true, "existing": true, "external": true, + } + if !validModes[d.Mode] { + return fmt.Errorf("invalid database mode: %s (must be shared, create, existing, or external)", d.Mode) + } + + switch d.Mode { + case "existing": + if d.ExistingContainer == "" { + return fmt.Errorf("existing_container is required for mode 'existing'") + } + case "external": + if d.ExternalHost == "" { + return fmt.Errorf("external_host is required for mode 'external'") + } + if d.ExternalPort <= 0 { + return fmt.Errorf("external_port must be a positive integer for mode 'external'") + } + } + + return nil +} + func (s *Server) createDeployment(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` @@ -580,6 +634,7 @@ func (s *Server) createDeployment(c *gin.Context) { AutoStart bool `json:"auto_start"` UseSharedDatabase bool `json:"use_shared_database"` ExistingDatabaseContainer string `json:"existing_database_container,omitempty"` + Databases []DatabaseConfigRequest `json:"databases,omitempty"` RegistryCredential *struct { CredentialID string `json:"credential_id,omitempty"` Username string `json:"username,omitempty"` @@ -669,7 +724,28 @@ func (s *Server) createDeployment(c *gin.Context) { } var dbEnvVars []EnvVar - if req.UseSharedDatabase && s.config.Infrastructure.Database.Enabled { + var databaseConfigs []models.DatabaseConfig + + if len(req.Databases) > 0 { + for i, dbReq := range req.Databases { + if err := dbReq.Validate(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("invalid database configuration at index %d: %s", i, err.Error()), + }) + return + } + } + + envVars, configs, err := s.createDatabasesForDeployment(req.Name, req.Databases) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Deployment created but failed to create databases: " + err.Error(), + }) + return + } + dbEnvVars = envVars + databaseConfigs = configs + } else if req.UseSharedDatabase && s.config.Infrastructure.Database.Enabled { dbResult, err := s.createDatabaseForDeployment(req.Name) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -678,6 +754,13 @@ func (s *Server) createDeployment(c *gin.Context) { return } dbEnvVars = dbResult + databaseConfigs = []models.DatabaseConfig{{ + ID: "primary", + Alias: "primary", + Type: s.config.Infrastructure.Database.Type, + Mode: "shared", + IsShared: true, + }} } allEnvVars := append(req.EnvVars, dbEnvVars...) @@ -696,6 +779,9 @@ func (s *Server) createDeployment(c *gin.Context) { } if req.Metadata != nil { + if len(databaseConfigs) > 0 { + req.Metadata.Databases = databaseConfigs + } if err := s.manager.SaveMetadata(req.Name, req.Metadata); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Deployment created but failed to save metadata: " + err.Error(), @@ -858,6 +944,160 @@ func (s *Server) createDatabaseForDeployment(deploymentName string) ([]EnvVar, e return envVars, nil } +func (s *Server) createDatabasesForDeployment(deploymentName string, databases []DatabaseConfigRequest) ([]EnvVar, []models.DatabaseConfig, error) { + var allEnvVars []EnvVar + var configs []models.DatabaseConfig + isFirst := true + + for i, dbReq := range databases { + alias := dbReq.Alias + if alias == "" { + if isFirst { + alias = "primary" + } else { + alias = fmt.Sprintf("db%d", i+1) + } + } + + envPrefix := dbReq.EnvPrefix + if envPrefix == "" { + envPrefix = strings.ToUpper(alias) + } + + config := models.DatabaseConfig{ + ID: fmt.Sprintf("%s-%s", deploymentName, alias), + Alias: alias, + Type: dbReq.Type, + Mode: dbReq.Mode, + Service: dbReq.Service, + EnvPrefix: envPrefix, + } + + var envVars []EnvVar + + switch dbReq.Mode { + case "shared": + if !s.config.Infrastructure.Database.Enabled { + continue + } + dbConfig := s.config.Infrastructure.Database + dbName := strings.ReplaceAll(deploymentName, "-", "_") + "_" + alias + "_db" + dbUser := strings.ReplaceAll(deploymentName, "-", "_") + "_" + alias + "_user" + dbPassword := generateRandomPassword(16) + + connConfig := &database.ConnectionConfig{ + Type: dbConfig.Type, + Host: dbConfig.Host, + Port: dbConfig.Port, + Username: dbConfig.RootUser, + Password: dbConfig.RootPassword, + Container: dbConfig.Container, + } + + if err := s.databaseManager.CreateDatabase(connConfig, dbName); err != nil { + return nil, nil, fmt.Errorf("failed to create database %s: %w", alias, err) + } + + if err := s.databaseManager.CreateUser(connConfig, dbUser, dbPassword, "%"); err != nil { + return nil, nil, fmt.Errorf("failed to create user for %s: %w", alias, err) + } + + if err := s.databaseManager.GrantPrivileges(connConfig, dbUser, dbName, "%"); err != nil { + return nil, nil, fmt.Errorf("failed to grant privileges for %s: %w", alias, err) + } + + dbHost := dbConfig.Host + if dbHost == "" { + dbHost = dbConfig.Container + } + + config.Host = dbHost + config.Port = dbConfig.Port + config.Container = dbConfig.Container + config.DatabaseName = dbName + config.Username = dbUser + config.IsShared = true + + envVars = s.generateDatabaseEnvVars(envPrefix, dbHost, dbConfig.Port, dbName, dbUser, dbPassword, dbConfig.Type, isFirst) + + case "existing": + config.Container = dbReq.ExistingContainer + config.Host = dbReq.ExistingContainer + if dbReq.DatabaseName != "" { + config.DatabaseName = dbReq.DatabaseName + } + if dbReq.Username != "" { + config.Username = dbReq.Username + } + + case "external": + config.Host = dbReq.ExternalHost + config.Port = dbReq.ExternalPort + if dbReq.DatabaseName != "" { + config.DatabaseName = dbReq.DatabaseName + } + if dbReq.Username != "" { + config.Username = dbReq.Username + } + if dbReq.Password != "" { + envVars = s.generateDatabaseEnvVars(envPrefix, dbReq.ExternalHost, dbReq.ExternalPort, dbReq.DatabaseName, dbReq.Username, dbReq.Password, dbReq.Type, isFirst) + } + } + + allEnvVars = append(allEnvVars, envVars...) + configs = append(configs, config) + isFirst = false + } + + return allEnvVars, configs, nil +} + +func (s *Server) generateDatabaseEnvVars(prefix string, host string, port int, dbName, username, password, dbType string, includeLegacy bool) []EnvVar { + var envVars []EnvVar + + envVars = append(envVars, + EnvVar{Key: prefix + "_HOST", Value: host}, + EnvVar{Key: prefix + "_PORT", Value: fmt.Sprintf("%d", port)}, + EnvVar{Key: prefix + "_DATABASE", Value: dbName}, + EnvVar{Key: prefix + "_USERNAME", Value: username}, + EnvVar{Key: prefix + "_PASSWORD", Value: password}, + ) + + var databaseURL string + switch dbType { + case "mysql", "mariadb": + databaseURL = fmt.Sprintf("mysql://%s:%s@%s:%d/%s", username, password, host, port, dbName) + case "postgres": + databaseURL = fmt.Sprintf("postgres://%s:%s@%s:%d/%s", username, password, host, port, dbName) + case "mongodb": + databaseURL = fmt.Sprintf("mongodb://%s:%s@%s:%d/%s", username, password, host, port, dbName) + case "redis": + if password != "" { + databaseURL = fmt.Sprintf("redis://:%s@%s:%d", password, host, port) + } else { + databaseURL = fmt.Sprintf("redis://%s:%d", host, port) + } + } + if databaseURL != "" { + envVars = append(envVars, EnvVar{Key: prefix + "_URL", Value: databaseURL}) + } + + if includeLegacy { + envVars = append(envVars, + EnvVar{Key: "DB_HOST", Value: host}, + EnvVar{Key: "DB_PORT", Value: fmt.Sprintf("%d", port)}, + EnvVar{Key: "DB_DATABASE", Value: dbName}, + EnvVar{Key: "DB_USERNAME", Value: username}, + EnvVar{Key: "DB_PASSWORD", Value: password}, + ) + if databaseURL != "" { + envVars = append(envVars, EnvVar{Key: "DATABASE_URL", Value: databaseURL}) + } + } + + return envVars +} + func (s *Server) writeEnvFile(deploymentName string, envVars []EnvVar) error { deploymentPath := filepath.Join(s.config.DeploymentsPath, deploymentName) envFilePath := filepath.Join(deploymentPath, ".env.flatrun") @@ -901,6 +1141,35 @@ func (s *Server) deleteDatabaseForDeployment(deploymentName string) error { return nil } +func (s *Server) deleteDatabaseByAlias(deploymentName, alias string) error { + dbConfig := s.config.Infrastructure.Database + dbName := strings.ReplaceAll(deploymentName, "-", "_") + "_" + alias + "_db" + dbUser := strings.ReplaceAll(deploymentName, "-", "_") + "_" + alias + "_user" + + connConfig := &database.ConnectionConfig{ + Type: dbConfig.Type, + Host: dbConfig.Host, + Port: dbConfig.Port, + Username: dbConfig.RootUser, + Password: dbConfig.RootPassword, + Container: dbConfig.Container, + } + + if err := s.databaseManager.RevokePrivileges(connConfig, dbUser, dbName); err != nil { + log.Printf("Warning: failed to revoke privileges for %s: %v", dbUser, err) + } + + if err := s.databaseManager.DropUser(connConfig, dbUser); err != nil { + log.Printf("Warning: failed to drop user %s: %v", dbUser, err) + } + + if err := s.databaseManager.DropDatabase(connConfig, dbName); err != nil { + return fmt.Errorf("failed to drop database %s: %w", alias, err) + } + + return nil +} + func (s *Server) getDeploymentEnv(c *gin.Context) { name := c.Param("name") deploymentPath := filepath.Join(s.config.DeploymentsPath, name) @@ -1119,23 +1388,38 @@ func (s *Server) deleteDeployment(c *gin.Context) { } } - if deployment != nil && deployment.Metadata != nil { - domain := deployment.Metadata.Networking.Domain + if deployment != nil && deployment.Metadata != nil && deleteSSL { + domainsToDelete := deployment.Metadata.GetUniqueDomainNames() + if len(domainsToDelete) == 0 && deployment.Metadata.Networking.Domain != "" { + domainsToDelete = []string{deployment.Metadata.Networking.Domain} + } - if deleteSSL && domain != "" && deployment.Metadata.SSL.Enabled { + for _, domain := range domainsToDelete { if err := s.proxyOrchestrator.SSLManager().DeleteCertificate(domain); err != nil { log.Printf("Warning: failed to delete SSL certificate for %s: %v", domain, err) } else { - deletedItems = append(deletedItems, "ssl_certificate") + deletedItems = append(deletedItems, fmt.Sprintf("ssl_certificate:%s", domain)) } } } if deleteDatabase && s.config.Infrastructure.Database.Enabled { - if err := s.deleteDatabaseForDeployment(name); err != nil { - log.Printf("Warning: failed to delete database for %s: %v", name, err) + if deployment != nil && deployment.Metadata != nil && len(deployment.Metadata.Databases) > 0 { + for _, dbConfig := range deployment.Metadata.Databases { + if dbConfig.IsShared { + if err := s.deleteDatabaseByAlias(name, dbConfig.Alias); err != nil { + log.Printf("Warning: failed to delete database %s for %s: %v", dbConfig.Alias, name, err) + } else { + deletedItems = append(deletedItems, fmt.Sprintf("database:%s", dbConfig.Alias)) + } + } + } } else { - deletedItems = append(deletedItems, "database") + if err := s.deleteDatabaseForDeployment(name); err != nil { + log.Printf("Warning: failed to delete database for %s: %v", name, err) + } else { + deletedItems = append(deletedItems, "database") + } } } @@ -3131,6 +3415,239 @@ func (s *Server) setupProxyWithRetry(deployment *models.Deployment, maxRetries i return nil, lastErr } +func (s *Server) listDomains(c *gin.Context) { + name := c.Param("name") + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + if deployment.Metadata == nil { + c.JSON(http.StatusOK, gin.H{"domains": []models.DomainConfig{}}) + return + } + + domains := deployment.Metadata.GetDomains() + c.JSON(http.StatusOK, gin.H{"domains": domains}) +} + +func (s *Server) addDomain(c *gin.Context) { + name := c.Param("name") + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + var domain models.DomainConfig + if err := c.ShouldBindJSON(&domain); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid domain data: " + err.Error()}) + return + } + + if domain.Domain == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Domain is required"}) + return + } + + if domain.ID == "" { + domain.ID = generateDomainID() + } + + if deployment.Metadata == nil { + deployment.Metadata = &models.ServiceMetadata{} + } + + if len(deployment.Metadata.Domains) == 0 && deployment.Metadata.Networking.Expose { + existingDomain := models.DomainConfig{ + ID: "default", + Service: deployment.Metadata.Name, + ContainerPort: deployment.Metadata.Networking.ContainerPort, + Domain: deployment.Metadata.Networking.Domain, + SSL: deployment.Metadata.SSL, + } + deployment.Metadata.Domains = []models.DomainConfig{existingDomain} + } + + deployment.Metadata.Domains = append(deployment.Metadata.Domains, domain) + + if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save domain: " + err.Error()}) + return + } + + var result *proxy.SetupResult + if s.proxyOrchestrator != nil { + var err error + result, err = s.proxyOrchestrator.SetupDeployment(deployment) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Failed to configure proxy: " + err.Error()}) + return + } + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Domain added successfully", + "domain": domain, + "proxy_result": result, + }) +} + +func (s *Server) updateDomain(c *gin.Context) { + name := c.Param("name") + domainID := c.Param("domainId") + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + if deployment.Metadata == nil || len(deployment.Metadata.Domains) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + + var updatedDomain models.DomainConfig + if err := c.ShouldBindJSON(&updatedDomain); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid domain data: " + err.Error()}) + return + } + + found := false + for i, d := range deployment.Metadata.Domains { + if d.ID == domainID { + updatedDomain.ID = domainID + deployment.Metadata.Domains[i] = updatedDomain + found = true + break + } + } + + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + + if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save domain: " + err.Error()}) + return + } + + result, err := s.proxyOrchestrator.SetupDeployment(deployment) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Failed to configure proxy: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Domain updated successfully", + "domain": updatedDomain, + "proxy_result": result, + }) +} + +func (s *Server) deleteDomain(c *gin.Context) { + name := c.Param("name") + domainID := c.Param("domainId") + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) + return + } + + if deployment.Metadata == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + + // Handle legacy "default" domain from networking config + if domainID == "default" && len(deployment.Metadata.Domains) == 0 { + if !deployment.Metadata.Networking.Expose || deployment.Metadata.Networking.Domain == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + // Clear the legacy networking config + deployment.Metadata.Networking.Expose = false + deployment.Metadata.Networking.Domain = "" + deployment.Metadata.SSL.Enabled = false + deployment.Metadata.SSL.AutoCert = false + + if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save metadata: " + err.Error()}) + return + } + + if s.proxyOrchestrator != nil { + if err := s.proxyOrchestrator.TeardownDeployment(name); err != nil { + log.Printf("Warning: failed to teardown proxy for %s: %v", name, err) + } + } + + c.JSON(http.StatusOK, gin.H{"message": "Domain deleted successfully"}) + return + } + + if len(deployment.Metadata.Domains) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + + found := false + newDomains := make([]models.DomainConfig, 0) + for _, d := range deployment.Metadata.Domains { + if d.ID == domainID { + found = true + continue + } + newDomains = append(newDomains, d) + } + + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Domain not found"}) + return + } + + if len(newDomains) == 0 { + deployment.Metadata.Domains = nil + } else { + deployment.Metadata.Domains = newDomains + } + + if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save metadata: " + err.Error()}) + return + } + + if s.proxyOrchestrator != nil { + if len(newDomains) == 0 { + if deployment.Metadata.Networking.Expose { + if _, err := s.proxyOrchestrator.SetupDeployment(deployment); err != nil { + log.Printf("Warning: failed to setup legacy proxy for %s: %v", name, err) + } + } else { + if err := s.proxyOrchestrator.TeardownDeployment(name); err != nil { + log.Printf("Warning: failed to teardown proxy for %s: %v", name, err) + } + } + } else { + if _, err := s.proxyOrchestrator.SetupDeployment(deployment); err != nil { + log.Printf("Warning: failed to update proxy for %s: %v", name, err) + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Domain deleted successfully", + }) +} + +func generateDomainID() string { + return uuid.New().String() +} + func (s *Server) getSystemStats(c *gin.Context) { deployments, err := s.manager.ListDeployments() if err != nil { diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 32a9102..ae1e917 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync" "text/template" @@ -83,39 +84,12 @@ func (m *Manager) CreateVirtualHost(deployment *models.Deployment) error { return fmt.Errorf("deployment has no metadata") } - if !deployment.Metadata.Networking.Expose { + domains := deployment.Metadata.GetDomains() + if len(domains) == 0 { return nil } - if deployment.Metadata.Networking.Domain == "" { - return fmt.Errorf("domain is required for exposed deployments") - } - - m.mu.Lock() - defer m.mu.Unlock() - - if err := os.MkdirAll(m.configPath, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - configContent, err := m.generateConfig(deployment) - if err != nil { - return fmt.Errorf("failed to generate nginx config: %w", err) - } - - configFile := filepath.Join(m.configPath, deployment.Name+".conf") - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - return fmt.Errorf("failed to write nginx config: %w", err) - } - - // Update per-deployment rate limits if security is enabled - if deployment.Metadata.Security != nil && deployment.Metadata.Security.Enabled { - if err := m.updateRateLimitsInternal(deployment.Name, deployment.Metadata.Security.RateLimits); err != nil { - return fmt.Errorf("failed to update rate limits: %w", err) - } - } - - return nil + return m.CreateMultiDomainVirtualHost(deployment) } func (m *Manager) DeleteVirtualHost(deploymentName string) error { @@ -369,6 +343,189 @@ func (m *Manager) generateConfig(deployment *models.Deployment) (string, error) return buf.String(), nil } +func (m *Manager) generateMultiDomainConfig(deployment *models.Deployment) (string, error) { + domains := deployment.Metadata.GetDomains() + if len(domains) == 0 { + return "", fmt.Errorf("no domains configured") + } + + securityEnabled := false + var blockedIPs []string + var rateLimits []rateLimitData + if deployment.Metadata.Security != nil && deployment.Metadata.Security.Enabled { + securityEnabled = true + blockedIPs = deployment.Metadata.Security.BlockedIPs + + for _, rl := range deployment.Metadata.Security.RateLimits { + if !rl.Enabled { + continue + } + zone := fmt.Sprintf("%s_%s", deployment.Name, sanitizeZoneName(rl.Path)) + burst := rl.Burst + if burst <= 0 { + burst = rl.Rate / 2 + if burst < 1 { + burst = 1 + } + } + rateLimits = append(rateLimits, rateLimitData{ + Path: rl.Path, + Zone: zone, + Rate: rl.Rate, + Burst: burst, + }) + } + } + + servers := m.groupDomainsByHost(domains, deployment.Name) + + data := multiRouteTemplateData{ + DeploymentName: deployment.Name, + ContainerWebrootPath: m.containerWebrootPath, + SecurityEnabled: securityEnabled, + BlockedIPs: blockedIPs, + RateLimits: rateLimits, + Servers: servers, + } + + allSSL := true + anySSL := false + for _, server := range servers { + if server.HasSSL { + anySSL = true + } else { + allSSL = false + } + } + + var tmplStr string + if allSSL { + tmplStr = multiRouteSSLTemplate + } else if anySSL { + tmplStr = multiRouteMixedTemplate + } else { + tmplStr = multiRouteHTTPTemplate + } + + tmpl, err := template.New("nginx-multi").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("failed to parse multi-domain template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute multi-domain template: %w", err) + } + + return buf.String(), nil +} + +func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentName string) []serverData { + hostDomains := make(map[string][]models.DomainConfig) + for _, d := range domains { + hostDomains[d.Domain] = append(hostDomains[d.Domain], d) + } + + var servers []serverData + for host, domainList := range hostDomains { + sort.Slice(domainList, func(i, j int) bool { + return len(domainList[i].PathPrefix) > len(domainList[j].PathPrefix) + }) + + var locations []locationData + hasSSL := false + sslDomain := host + var serverAliases []string + + for _, d := range domainList { + path := d.PathPrefix + if path == "" { + path = "/" + } + + service := d.Service + if service == "" { + service = deploymentName + } + + port := d.ContainerPort + if port == 0 { + port = 80 + } + + locations = append(locations, locationData{ + Path: path, + Service: service, + ContainerPort: port, + Protocol: "http", + StripPrefix: d.StripPrefix, + OriginalPath: d.PathPrefix, + }) + + if d.SSL.Enabled { + hasSSL = true + } + + for _, alias := range d.Aliases { + if alias != host { + serverAliases = append(serverAliases, alias) + } + } + } + + servers = append(servers, serverData{ + Domain: host, + SSLEnabled: hasSSL, + HasSSL: hasSSL, + SSLDomain: sslDomain, + Locations: locations, + ServerAliases: serverAliases, + }) + } + + sort.Slice(servers, func(i, j int) bool { + return servers[i].Domain < servers[j].Domain + }) + + return servers +} + +func (m *Manager) CreateMultiDomainVirtualHost(deployment *models.Deployment) error { + if deployment.Metadata == nil { + return fmt.Errorf("deployment has no metadata") + } + + domains := deployment.Metadata.GetDomains() + if len(domains) == 0 { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := os.MkdirAll(m.configPath, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + configContent, err := m.generateMultiDomainConfig(deployment) + if err != nil { + return fmt.Errorf("failed to generate multi-domain nginx config: %w", err) + } + + configFile := filepath.Join(m.configPath, deployment.Name+".conf") + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + return fmt.Errorf("failed to write nginx config: %w", err) + } + + if deployment.Metadata.Security != nil && deployment.Metadata.Security.Enabled { + if err := m.updateRateLimitsInternal(deployment.Name, deployment.Metadata.Security.RateLimits); err != nil { + return fmt.Errorf("failed to update rate limits: %w", err) + } + } + + return nil +} + type VirtualHostInfo struct { Name string `json:"name"` ConfigFile string `json:"config_file"` @@ -389,6 +546,33 @@ type templateData struct { RateLimits []rateLimitData } +type multiRouteTemplateData struct { + DeploymentName string + ContainerWebrootPath string + SecurityEnabled bool + BlockedIPs []string + RateLimits []rateLimitData + Servers []serverData +} + +type serverData struct { + Domain string + SSLEnabled bool + Locations []locationData + HasSSL bool + SSLDomain string + ServerAliases []string +} + +type locationData struct { + Path string + Service string + ContainerPort int + Protocol string + StripPrefix bool + OriginalPath string +} + type rateLimitData struct { Path string Zone string @@ -534,6 +718,284 @@ server { } ` +const multiRouteHTTPTemplate = `{{- range .Servers}} +server { + listen 80; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + resolver 127.0.0.11 valid=30s ipv6=off; +{{- range $.BlockedIPs}} + deny {{.}}; +{{- end}} +{{- range .Locations}} + + location {{.Path}} { + set $upstream {{.Service}}:{{.ContainerPort}}; +{{- if .StripPrefix}} + rewrite ^{{.OriginalPath}}(.*)$ /$1 break; +{{- end}} + proxy_pass {{.Protocol}}://$upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +{{- range $.RateLimits}} + + location {{.Path}} { + limit_req zone={{.Zone}} burst={{.Burst}} nodelay; + limit_req_status 429; + set $upstream {{$.DeploymentName}}:80; + proxy_pass http://$upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} + + location /.well-known/acme-challenge/ { + root {{$.ContainerWebrootPath}}; + } +} +{{end}}` + +const multiRouteSSLTemplate = `{{- range .Servers}} +server { + listen 80; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + location /.well-known/acme-challenge/ { + root {{$.ContainerWebrootPath}}; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + ssl_certificate /etc/letsencrypt/live/{{.SSLDomain}}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{.SSLDomain}}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_stapling on; + ssl_stapling_verify on; + + add_header Strict-Transport-Security "max-age=63072000" always; + + resolver 127.0.0.11 valid=30s ipv6=off; +{{- range $.BlockedIPs}} + deny {{.}}; +{{- end}} +{{- range .Locations}} + + location {{.Path}} { + set $upstream {{.Service}}:{{.ContainerPort}}; +{{- if .StripPrefix}} + rewrite ^{{.OriginalPath}}(.*)$ /$1 break; +{{- end}} + proxy_pass {{.Protocol}}://$upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +{{- range $.RateLimits}} + + location {{.Path}} { + limit_req zone={{.Zone}} burst={{.Burst}} nodelay; + limit_req_status 429; + set $upstream {{$.DeploymentName}}:80; + proxy_pass http://$upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +} +{{end}}` + +const multiRouteMixedTemplate = `{{- range .Servers}} +{{- if .HasSSL}} +server { + listen 80; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + location /.well-known/acme-challenge/ { + root {{$.ContainerWebrootPath}}; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + ssl_certificate /etc/letsencrypt/live/{{.SSLDomain}}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{.SSLDomain}}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_stapling on; + ssl_stapling_verify on; + + add_header Strict-Transport-Security "max-age=63072000" always; + + resolver 127.0.0.11 valid=30s ipv6=off; +{{- range $.BlockedIPs}} + deny {{.}}; +{{- end}} +{{- range .Locations}} + + location {{.Path}} { + set $upstream {{.Service}}:{{.ContainerPort}}; +{{- if .StripPrefix}} + rewrite ^{{.OriginalPath}}(.*)$ /$1 break; +{{- end}} + proxy_pass {{.Protocol}}://$upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +{{- range $.RateLimits}} + + location {{.Path}} { + limit_req zone={{.Zone}} burst={{.Burst}} nodelay; + limit_req_status 429; + set $upstream {{$.DeploymentName}}:80; + proxy_pass http://$upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +} +{{- else}} +server { + listen 80; + server_name {{.Domain}}{{range .ServerAliases}} {{.}}{{end}}; + + resolver 127.0.0.11 valid=30s ipv6=off; +{{- range $.BlockedIPs}} + deny {{.}}; +{{- end}} +{{- range .Locations}} + + location {{.Path}} { + set $upstream {{.Service}}:{{.ContainerPort}}; +{{- if .StripPrefix}} + rewrite ^{{.OriginalPath}}(.*)$ /$1 break; +{{- end}} + proxy_pass {{.Protocol}}://$upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} +{{- range $.RateLimits}} + + location {{.Path}} { + limit_req zone={{.Zone}} burst={{.Burst}} nodelay; + limit_req_status 429; + set $upstream {{$.DeploymentName}}:80; + proxy_pass http://$upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +{{- if $.SecurityEnabled}} + log_by_lua_block { + security.capture_event() + } +{{- end}} + } +{{- end}} + + location /.well-known/acme-challenge/ { + root {{$.ContainerWebrootPath}}; + } +} +{{- end}} +{{end}}` + func sanitizeZoneName(path string) string { name := strings.ReplaceAll(path, "/", "_") name = strings.ReplaceAll(name, "*", "") diff --git a/internal/proxy/orchestrator.go b/internal/proxy/orchestrator.go index 9f4d64e..fff111d 100644 --- a/internal/proxy/orchestrator.go +++ b/internal/proxy/orchestrator.go @@ -47,29 +47,46 @@ func (o *Orchestrator) SetupDeployment(deployment *models.Deployment) (*SetupRes DeploymentName: deployment.Name, } - if deployment.Metadata == nil || !deployment.Metadata.Networking.Expose { + if deployment.Metadata == nil { result.Skipped = true - result.Message = "deployment not configured for exposure" + result.Message = "deployment has no metadata" return result, nil } - domain := deployment.Metadata.Networking.Domain - if domain == "" { - return nil, fmt.Errorf("domain is required for exposed deployments") + domains := deployment.Metadata.GetDomains() + if len(domains) == 0 { + result.Skipped = true + result.Message = "deployment not configured for exposure" + return result, nil } - if err := o.ssl.ValidateDomain(domain); err != nil { - return nil, fmt.Errorf("invalid domain: %w", err) + return o.setupMultiDomainDeployment(deployment, domains) +} + +func (o *Orchestrator) setupMultiDomainDeployment(deployment *models.Deployment, domains []models.DomainConfig) (*SetupResult, error) { + result := &SetupResult{ + DeploymentName: deployment.Name, + Domains: deployment.Metadata.GetUniqueDomainNames(), } - result.Domain = domain + for _, domainName := range result.Domains { + if err := o.ssl.ValidateDomain(domainName); err != nil { + return nil, fmt.Errorf("invalid domain %s: %w", domainName, err) + } + } - wantsSSL := deployment.Metadata.SSL.Enabled && deployment.Metadata.SSL.AutoCert - certExists := o.ssl.CertificateExists(domain) + if len(result.Domains) > 0 { + result.Domain = result.Domains[0] + } - if wantsSSL && !certExists { - deployment.Metadata.SSL.Enabled = false + for i := range domains { + if domains[i].SSL.Enabled && domains[i].SSL.AutoCert { + if !o.ssl.CertificateExists(domains[i].Domain) { + domains[i].SSL.Enabled = false + } + } } + deployment.Metadata.Domains = domains if err := o.nginx.CreateVirtualHost(deployment); err != nil { return nil, fmt.Errorf("failed to create virtual host: %w", err) @@ -87,37 +104,32 @@ func (o *Orchestrator) SetupDeployment(deployment *models.Deployment) (*SetupRes result.NginxReloaded = true } - if wantsSSL { - if certExists { + certResults, err := o.ssl.RequestCertificatesForDomains(domains) + if err != nil { + log.Printf("warning: failed to request certificates: %v", err) + result.SSLError = err.Error() + } else { + result.CertificateResults = certResults.Results + result.CertificateRequested = len(certResults.Results) > 0 + if certResults.Success { result.CertificateExists = true - } else { - certResult, err := o.ssl.RequestCertificate(domain) - if err != nil { - log.Printf("warning: failed to request certificate for %s: %v", domain, err) - result.SSLError = err.Error() - } else { - result.CertificateRequested = true - result.SSLMessage = certResult.Message - // Verify certificate was actually created - if o.ssl.CertificateExists(domain) { - result.CertificateExists = true - } else { - log.Printf("warning: certificate request succeeded but cert not found for %s", domain) - result.CertificateRequested = false - result.SSLError = "certificate request succeeded but certificate file not found" - } + } + + needsUpdate := false + for i := range domains { + if domains[i].SSL.AutoCert && o.ssl.CertificateExists(domains[i].Domain) { + domains[i].SSL.Enabled = true + needsUpdate = true } } - if result.CertificateExists { - deployment.Metadata.SSL.Enabled = true + if needsUpdate { + deployment.Metadata.Domains = domains if err := o.nginx.UpdateVirtualHost(deployment); err != nil { log.Printf("warning: failed to update virtual host with SSL: %v", err) } if err := o.nginx.TestConfig(); err != nil { - log.Printf("warning: SSL config test failed, reverting to HTTP: %v", err) - deployment.Metadata.SSL.Enabled = false - _ = o.nginx.UpdateVirtualHost(deployment) + log.Printf("warning: SSL config test failed: %v", err) } if err := o.nginx.Reload(); err != nil { log.Printf("warning: failed to reload nginx after SSL: %v", err) @@ -142,7 +154,9 @@ func (o *Orchestrator) TeardownDeployment(deploymentName string) error { } func (o *Orchestrator) UpdateDeployment(deployment *models.Deployment) (*SetupResult, error) { - if deployment.Metadata == nil || !deployment.Metadata.Networking.Expose { + hasDomains := deployment.Metadata != nil && len(deployment.Metadata.GetDomains()) > 0 + + if !hasDomains { if o.nginx.VirtualHostExists(deployment.Name) { if err := o.TeardownDeployment(deployment.Name); err != nil { return nil, err @@ -197,18 +211,36 @@ func (o *Orchestrator) GetDeploymentProxyStatus(deployment *models.Deployment) * return status } - status.Exposed = deployment.Metadata.Networking.Expose - status.Domain = deployment.Metadata.Networking.Domain + domainConfigs := deployment.Metadata.GetDomains() + if len(domainConfigs) == 0 { + return status + } + + status.DomainConfigs = domainConfigs + status.Domains = deployment.Metadata.GetUniqueDomainNames() + status.Exposed = true status.VirtualHostExists = o.nginx.VirtualHostExists(deployment.Name) - if deployment.Metadata.SSL.Enabled && status.Domain != "" { - status.SSLEnabled = true - status.CertificateExists = o.ssl.CertificateExists(status.Domain) + if len(status.Domains) > 0 { + status.Domain = status.Domains[0] + } - if status.CertificateExists { - cert, err := o.ssl.GetCertificate(status.Domain) + for _, d := range domainConfigs { + if d.SSL.Enabled { + status.SSLEnabled = true + break + } + } + + for _, domainName := range status.Domains { + if o.ssl.CertificateExists(domainName) { + status.CertificateExists = true + cert, err := o.ssl.GetCertificate(domainName) if err == nil { - status.Certificate = cert + status.Certificates = append(status.Certificates, *cert) + if status.Certificate == nil { + status.Certificate = cert + } } } } @@ -229,25 +261,30 @@ func (o *Orchestrator) GetExpiringCertificates(days int) ([]models.Certificate, } type SetupResult struct { - DeploymentName string `json:"deployment_name"` - Domain string `json:"domain,omitempty"` - Success bool `json:"success"` - Skipped bool `json:"skipped"` - Message string `json:"message,omitempty"` - VirtualHostCreated bool `json:"virtual_host_created"` - NginxReloaded bool `json:"nginx_reloaded"` - CertificateRequested bool `json:"certificate_requested"` - CertificateExists bool `json:"certificate_exists"` - SSLMessage string `json:"ssl_message,omitempty"` - SSLError string `json:"ssl_error,omitempty"` + DeploymentName string `json:"deployment_name"` + Domain string `json:"domain,omitempty"` + Domains []string `json:"domains,omitempty"` + Success bool `json:"success"` + Skipped bool `json:"skipped"` + Message string `json:"message,omitempty"` + VirtualHostCreated bool `json:"virtual_host_created"` + NginxReloaded bool `json:"nginx_reloaded"` + CertificateRequested bool `json:"certificate_requested"` + CertificateExists bool `json:"certificate_exists"` + CertificateResults []*ssl.CertificateResult `json:"certificate_results,omitempty"` + SSLMessage string `json:"ssl_message,omitempty"` + SSLError string `json:"ssl_error,omitempty"` } type ProxyStatus struct { - DeploymentName string `json:"deployment_name"` - Exposed bool `json:"exposed"` - Domain string `json:"domain,omitempty"` - VirtualHostExists bool `json:"virtual_host_exists"` - SSLEnabled bool `json:"ssl_enabled"` - CertificateExists bool `json:"certificate_exists"` - Certificate *models.Certificate `json:"certificate,omitempty"` + DeploymentName string `json:"deployment_name"` + Exposed bool `json:"exposed"` + Domain string `json:"domain,omitempty"` + Domains []string `json:"domains,omitempty"` + DomainConfigs []models.DomainConfig `json:"domains_config,omitempty"` + VirtualHostExists bool `json:"virtual_host_exists"` + SSLEnabled bool `json:"ssl_enabled"` + CertificateExists bool `json:"certificate_exists"` + Certificate *models.Certificate `json:"certificate,omitempty"` + Certificates []models.Certificate `json:"certificates,omitempty"` } diff --git a/internal/ssl/manager.go b/internal/ssl/manager.go index 3393e9d..fa54f60 100644 --- a/internal/ssl/manager.go +++ b/internal/ssl/manager.go @@ -320,3 +320,67 @@ type RenewalResult struct { Message string `json:"message"` RenewedDomains []string `json:"renewed_domains,omitempty"` } + +type MultiCertificateResult struct { + Results []*CertificateResult `json:"results"` + Success bool `json:"success"` +} + +func (m *Manager) RequestCertificatesForDomains(domains []models.DomainConfig) (*MultiCertificateResult, error) { + domainSet := make(map[string]bool) + for _, d := range domains { + if d.SSL.Enabled && d.SSL.AutoCert && d.Domain != "" { + domainSet[d.Domain] = true + for _, alias := range d.Aliases { + domainSet[alias] = true + } + } + } + + result := &MultiCertificateResult{ + Results: make([]*CertificateResult, 0), + Success: true, + } + + for domain := range domainSet { + if m.CertificateExists(domain) { + result.Results = append(result.Results, &CertificateResult{ + Domain: domain, + Success: true, + Message: "Certificate already exists", + }) + continue + } + + certResult, err := m.RequestCertificate(domain) + if err != nil { + result.Results = append(result.Results, &CertificateResult{ + Domain: domain, + Success: false, + Message: err.Error(), + }) + result.Success = false + } else { + result.Results = append(result.Results, certResult) + } + } + + return result, nil +} + +func (m *Manager) GetDomainsNeedingCertificates(domains []models.DomainConfig) []string { + var result []string + for _, d := range domains { + if d.SSL.Enabled && d.SSL.AutoCert && d.Domain != "" { + if !m.CertificateExists(d.Domain) { + result = append(result, d.Domain) + } + for _, alias := range d.Aliases { + if !m.CertificateExists(alias) { + result = append(result, alias) + } + } + } + } + return result +} diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index fa21ec8..1557489 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -33,6 +33,90 @@ type ServiceMetadata struct { Security *DeploymentSecurityConfig `yaml:"security,omitempty" json:"security,omitempty"` Backup *BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"` CredentialID string `yaml:"credential_id,omitempty" json:"credential_id,omitempty"` + Domains []DomainConfig `yaml:"domains,omitempty" json:"domains,omitempty"` + Databases []DatabaseConfig `yaml:"databases,omitempty" json:"databases,omitempty"` +} + +type DomainConfig struct { + ID string `yaml:"id" json:"id"` + Service string `yaml:"service" json:"service"` + ContainerPort int `yaml:"container_port" json:"container_port"` + Domain string `yaml:"domain" json:"domain"` + PathPrefix string `yaml:"path_prefix,omitempty" json:"path_prefix,omitempty"` + StripPrefix bool `yaml:"strip_prefix,omitempty" json:"strip_prefix,omitempty"` + SSL SSLConfig `yaml:"ssl" json:"ssl"` + Aliases []string `yaml:"aliases,omitempty" json:"aliases,omitempty"` +} + +type DatabaseConfig struct { + ID string `yaml:"id" json:"id"` + Alias string `yaml:"alias" json:"alias"` + Type string `yaml:"type" json:"type"` + Mode string `yaml:"mode" json:"mode"` + Service string `yaml:"service,omitempty" json:"service,omitempty"` + Host string `yaml:"host,omitempty" json:"host,omitempty"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Container string `yaml:"container,omitempty" json:"container,omitempty"` + DatabaseName string `yaml:"database_name,omitempty" json:"database_name,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + EnvPrefix string `yaml:"env_prefix,omitempty" json:"env_prefix,omitempty"` + IsShared bool `yaml:"is_shared,omitempty" json:"is_shared,omitempty"` +} + +func (m *ServiceMetadata) GetDomains() []DomainConfig { + if len(m.Domains) > 0 { + return m.Domains + } + if !m.Networking.Expose || m.Networking.Domain == "" { + return nil + } + return []DomainConfig{{ + ID: "default", + Service: m.Name, + ContainerPort: m.Networking.ContainerPort, + Domain: m.Networking.Domain, + SSL: m.SSL, + }} +} + +func (m *ServiceMetadata) GetUniqueDomainNames() []string { + domains := m.GetDomains() + domainSet := make(map[string]struct{}) + for _, d := range domains { + domainSet[d.Domain] = struct{}{} + for _, alias := range d.Aliases { + domainSet[alias] = struct{}{} + } + } + result := make([]string, 0, len(domainSet)) + for name := range domainSet { + result = append(result, name) + } + return result +} + +func (m *ServiceMetadata) HasMultipleDomains() bool { + return len(m.Domains) > 1 +} + +func (m *ServiceMetadata) GetDatabases() []DatabaseConfig { + return m.Databases +} + +func (m *ServiceMetadata) GetPrimaryDatabase() *DatabaseConfig { + if len(m.Databases) == 0 { + return nil + } + for i := range m.Databases { + if m.Databases[i].Alias == "primary" { + return &m.Databases[i] + } + } + return &m.Databases[0] +} + +func (m *ServiceMetadata) HasMultipleDatabases() bool { + return len(m.Databases) > 1 } type BackupSpec struct { diff --git a/pkg/models/deployment_test.go b/pkg/models/deployment_test.go new file mode 100644 index 0000000..521cc05 --- /dev/null +++ b/pkg/models/deployment_test.go @@ -0,0 +1,278 @@ +package models + +import ( + "testing" +) + +func TestServiceMetadata_GetDatabases(t *testing.T) { + tests := []struct { + name string + metadata ServiceMetadata + want int + }{ + { + name: "empty databases", + metadata: ServiceMetadata{}, + want: 0, + }, + { + name: "single database", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "primary", Type: "mysql"}, + }, + }, + want: 1, + }, + { + name: "multiple databases", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "primary", Type: "mysql"}, + {ID: "db2", Alias: "cache", Type: "redis"}, + {ID: "db3", Alias: "analytics", Type: "postgres"}, + }, + }, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.metadata.GetDatabases() + if len(got) != tt.want { + t.Errorf("GetDatabases() returned %d databases, want %d", len(got), tt.want) + } + }) + } +} + +func TestServiceMetadata_GetPrimaryDatabase(t *testing.T) { + tests := []struct { + name string + metadata ServiceMetadata + wantAlias string + wantNil bool + }{ + { + name: "empty databases returns nil", + metadata: ServiceMetadata{}, + wantNil: true, + }, + { + name: "returns database with primary alias", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "cache", Type: "redis"}, + {ID: "db2", Alias: "primary", Type: "mysql"}, + {ID: "db3", Alias: "analytics", Type: "postgres"}, + }, + }, + wantAlias: "primary", + }, + { + name: "returns first database if no primary alias", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "cache", Type: "redis"}, + {ID: "db2", Alias: "main", Type: "mysql"}, + }, + }, + wantAlias: "cache", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.metadata.GetPrimaryDatabase() + if tt.wantNil { + if got != nil { + t.Errorf("GetPrimaryDatabase() = %v, want nil", got) + } + return + } + if got == nil { + t.Error("GetPrimaryDatabase() returned nil, want non-nil") + return + } + if got.Alias != tt.wantAlias { + t.Errorf("GetPrimaryDatabase().Alias = %s, want %s", got.Alias, tt.wantAlias) + } + }) + } +} + +func TestServiceMetadata_HasMultipleDatabases(t *testing.T) { + tests := []struct { + name string + metadata ServiceMetadata + want bool + }{ + { + name: "empty databases", + metadata: ServiceMetadata{}, + want: false, + }, + { + name: "single database", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "primary", Type: "mysql"}, + }, + }, + want: false, + }, + { + name: "multiple databases", + metadata: ServiceMetadata{ + Databases: []DatabaseConfig{ + {ID: "db1", Alias: "primary", Type: "mysql"}, + {ID: "db2", Alias: "cache", Type: "redis"}, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.metadata.HasMultipleDatabases(); got != tt.want { + t.Errorf("HasMultipleDatabases() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestServiceMetadata_GetDomains(t *testing.T) { + tests := []struct { + name string + metadata ServiceMetadata + want int + }{ + { + name: "returns Domains array when present", + metadata: ServiceMetadata{ + Domains: []DomainConfig{ + {ID: "d1", Domain: "example.com"}, + {ID: "d2", Domain: "api.example.com"}, + }, + }, + want: 2, + }, + { + name: "falls back to networking domain", + metadata: ServiceMetadata{ + Name: "myapp", + Networking: NetworkingConfig{ + Expose: true, + Domain: "myapp.example.com", + ContainerPort: 8080, + }, + SSL: SSLConfig{Enabled: true}, + }, + want: 1, + }, + { + name: "returns nil when not exposed", + metadata: ServiceMetadata{ + Networking: NetworkingConfig{ + Expose: false, + }, + }, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.metadata.GetDomains() + if len(got) != tt.want { + t.Errorf("GetDomains() returned %d domains, want %d", len(got), tt.want) + } + }) + } +} + +func TestServiceMetadata_GetUniqueDomainNames(t *testing.T) { + tests := []struct { + name string + metadata ServiceMetadata + want int + }{ + { + name: "returns unique domain names including aliases", + metadata: ServiceMetadata{ + Domains: []DomainConfig{ + { + ID: "d1", + Domain: "example.com", + Aliases: []string{"www.example.com"}, + }, + { + ID: "d2", + Domain: "api.example.com", + }, + }, + }, + want: 3, + }, + { + name: "deduplicates domains", + metadata: ServiceMetadata{ + Domains: []DomainConfig{ + { + ID: "d1", + Domain: "example.com", + Aliases: []string{"example.com"}, + }, + }, + }, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.metadata.GetUniqueDomainNames() + if len(got) != tt.want { + t.Errorf("GetUniqueDomainNames() returned %d names, want %d", len(got), tt.want) + } + }) + } +} + +func TestDatabaseConfig_Fields(t *testing.T) { + db := DatabaseConfig{ + ID: "test-db-1", + Alias: "primary", + Type: "mysql", + Mode: "shared", + Service: "backend", + Host: "localhost", + Port: 3306, + Container: "mysql-container", + DatabaseName: "myapp_db", + Username: "myapp_user", + EnvPrefix: "PRIMARY", + IsShared: true, + } + + if db.ID != "test-db-1" { + t.Errorf("ID = %s, want test-db-1", db.ID) + } + if db.Alias != "primary" { + t.Errorf("Alias = %s, want primary", db.Alias) + } + if db.Type != "mysql" { + t.Errorf("Type = %s, want mysql", db.Type) + } + if db.Mode != "shared" { + t.Errorf("Mode = %s, want shared", db.Mode) + } + if db.Port != 3306 { + t.Errorf("Port = %d, want 3306", db.Port) + } + if !db.IsShared { + t.Error("IsShared = false, want true") + } +}