package service import ( "context" "fmt" "net" "strconv" "strings" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" ) // createInitialComponentDeployment creates a K8s Deployment for a newly added component. // This ensures the deployment exists before CI runs, so kubectl set image succeeds. // Deployments start with 0 replicas to avoid ImagePullBackOff (image doesn't exist yet). // The CI deploy step scales to 1 after building and verifying the image. // For monorepo projects, updates the project's unified Ingress with path-based routing. // Failures are logged but don't fail the component creation. func (s *ComponentService) createInitialComponentDeployment( ctx context.Context, projectID, projectDomain string, component *domain.Component, ) { // Skip if no deployer or component doesn't need a deployment if s.deployer == nil || !component.Type.NeedsPort() { return } // Build the image path - uses "latest" as placeholder until CI builds a real image image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name) // Assign URL path based on component type basePath := assignComponentPath(component) // Build sibling service URLs for service discovery siblingServices := s.buildSiblingServiceURLs(ctx, projectID, component.Name) // Fetch project credentials (DATABASE_URL, REDIS_URL, etc.) from credential store secrets := s.fetchProjectCredentials(ctx, projectID) // Look up Citadel labels for log routing extraLabels := s.fetchCitadelLabels(ctx, projectID, component.Name) spec := domain.DeploySpec{ ProjectName: projectID, ComponentPath: component.Path, Image: image, Domain: projectDomain, Port: component.Port, Replicas: -1, // Negative = create with 0 replicas (no pods until CI builds the image) BasePath: basePath, SiblingServices: siblingServices, Secrets: secrets, ExtraLabels: extraLabels, } log := logging.FromContext(ctx).WithService("component") // Create Deployment and Service (without Ingress - we manage that separately) if err := s.deployer.Deploy(ctx, spec); err != nil { log.Warn("failed to create initial component deployment", logging.FieldProjectID, projectID, "component", component.Name, logging.FieldError, err, ) return } // Add path to project's unified Ingress serviceName := spec.DeploymentName() if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil { log.Warn("failed to add ingress path for component", logging.FieldProjectID, projectID, "component", component.Name, "path", basePath, logging.FieldError, err, ) // Continue anyway - the deployment/service exist and CI will work } log.Info("created initial component deployment", logging.FieldProjectID, projectID, "component", component.Name, "image", image, "path", basePath, ) } // assignComponentPath determines the URL path for a component. // Services get /api/{name}, apps get /. // // LIMITATION: All app components (app-react, app-astro, app-nextjs) get "/" path. // If a project has multiple apps, they will conflict on the same path. // Future enhancement: track assigned paths and use /apps/{name} for additional apps. func assignComponentPath(component *domain.Component) string { switch { case component.Type == domain.ComponentTypeService: return "/api/" + component.Name case component.Type.IsAppComponent(): return "/" default: return "" // Workers, CLI don't get HTTP paths } } // assignPort finds the next available port for a component type. func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) { // Get existing components to find the highest used port components, err := s.ListComponents(ctx, projectID) if err != nil { return 0, err } startingPort := componentType.StartingPort() if startingPort == 0 { return 0, nil // Component type doesn't need a port } maxPort := startingPort - 1 for _, c := range components { // Only consider components that share the same port range if c.Type.StartingPort() == startingPort && c.Port > maxPort { maxPort = c.Port } } return maxPort + 1, nil } // buildSiblingServiceURLs constructs service discovery URLs for all sibling services. // Returns a map of env var names to internal K8s service URLs. // Example: {"AUTH_SVC_URL": "http://myproject-auth-svc:8001", "CHAT_SVC_URL": "http://myproject-chat-svc:8002"} func (s *ComponentService) buildSiblingServiceURLs(ctx context.Context, projectID, currentComponent string) map[string]string { components, err := s.ListComponents(ctx, projectID) if err != nil { log := logging.FromContext(ctx).WithService("component") log.Warn("failed to list components for sibling discovery", logging.FieldProjectID, projectID, logging.FieldError, err, ) return nil } urls := make(map[string]string) for _, c := range components { // Skip the current component, non-service types, and components without ports if c.Name == currentComponent || c.Type != domain.ComponentTypeService || c.Port == 0 { continue } // Build env var name: auth-svc -> AUTH_SVC_URL envKey := toUpperSnake(c.Name) + "_URL" // Build internal K8s service URL: http://projectid-componentname:port serviceName := projectID + "-" + c.Name urls[envKey] = "http://" + net.JoinHostPort(serviceName, strconv.Itoa(c.Port)) } return urls } // toUpperSnake converts a kebab-case string to UPPER_SNAKE_CASE. // Example: "auth-svc" -> "AUTH_SVC" func toUpperSnake(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) } // fetchProjectCredentials retrieves stored infrastructure credentials for a project. // Returns a map of env var names to values (e.g., {"DATABASE_URL": "postgres://...", "REDIS_URL": "redis://..."}). // Missing credentials are silently skipped - not all projects have all infrastructure. func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectID string) map[string]string { if s.credentialStore == nil { return nil } // Project-scoped credentials (stored as "{projectID}:{key}") projectScopedKeys := []string{ "DATABASE_URL", "DATABASE_URL_STAGING", "REDIS_URL", "REDIS_URL_STAGING", "REDIS_PREFIX", domain.CredKeyGCSBucket, domain.CredKeyGCSServiceAccountJSON, } // Global credentials (stored without project prefix, shared across all projects) globalKeys := []string{ domain.CredKeyLaozhangAPIKey, domain.CredKeyGeminiAPIKey, } secrets := make(map[string]string) log := logging.FromContext(ctx).WithService("component") // Fetch project-scoped credentials if len(projectScopedKeys) > 0 { scopedKeys := make([]string, len(projectScopedKeys)) for i, key := range projectScopedKeys { scopedKeys[i] = projectID + ":" + key } creds, err := s.credentialStore.GetMultiple(ctx, scopedKeys) if err != nil { log.Warn("failed to fetch project-scoped credentials", logging.FieldProjectID, projectID, logging.FieldError, err, ) } else { for scopedKey, value := range creds { // Extract env var name: "myproject:DATABASE_URL" -> "DATABASE_URL" parts := strings.SplitN(scopedKey, ":", 2) if len(parts) == 2 && value != "" { secrets[parts[1]] = value } } } } // Fetch global credentials (AI providers, etc.) if len(globalKeys) > 0 { creds, err := s.credentialStore.GetMultiple(ctx, globalKeys) if err != nil { log.Warn("failed to fetch global credentials", logging.FieldProjectID, projectID, logging.FieldError, err, ) } else { for key, value := range creds { if value != "" { secrets[key] = value } } } } if len(secrets) > 0 { log.Debug("fetched credentials for deployment", logging.FieldProjectID, projectID, "credential_count", len(secrets), ) } return secrets } // fetchCitadelLabels looks up the project's Citadel tenant_id and slug from the database // and returns k8s labels for agent log routing. Returns nil if no Citadel environment exists. func (s *ComponentService) fetchCitadelLabels(ctx context.Context, projectID, serviceName string) map[string]string { var slug, tenantID string err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(slug, ''), COALESCE(citadel_tenant_id, '') FROM projects WHERE id = $1 `, projectID).Scan(&slug, &tenantID) if err != nil || tenantID == "" { return nil } return map[string]string{ "citadel.io/environment": slug, "citadel.io/service": serviceName, } }