package service import ( "context" "database/sql" "fmt" "strings" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" ) // CreateProject creates a new project with git repo and DNS. // This is the main orchestration method for /project create. func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) { log := logging.FromContext(ctx).WithService("project_infra") // Validate project name first if err := ValidateProjectName(req.Name); err != nil { return nil, fmt.Errorf("%w: %w", domain.ErrInvalidProjectName, err) } // Validate custom subdomain if provided if req.CustomSubdomain != "" { if err := domain.ValidateSubdomain(req.CustomSubdomain); err != nil { return nil, fmt.Errorf("invalid custom subdomain: %w", err) } } log.Info("creating project", "name", req.Name) // 1. Generate unique slug slug, err := s.generateSlug(ctx, req.Name) if err != nil { return nil, err } // 2. Create project in database with slug projectID := req.Name // Use name as ID for simplicity if err := s.createProjectInDB(ctx, projectID, req, slug); err != nil { return nil, err } // Primary auto domain uses slug autoDomain := slug + "." + s.defaultDomain result := &CreateProjectResult{ ProjectID: projectID, Name: req.Name, Description: req.Description, Slug: slug, Domain: autoDomain, } result.URL = "https://" + result.Domain // 3. Create git repository s.createGitRepo(ctx, req, result, projectID) // 4. Create DNS record for primary auto domain (slug-based) s.createPrimaryDNS(ctx, slug, autoDomain, projectID, result) // 5. Create custom subdomain if requested s.createCustomDNS(ctx, req, projectID, result) // 6. Create docs subdomain for API documentation (docs.{slug}.{domain}) s.createDocsDNS(ctx, slug, projectID, result) // 7. Activate CI (Woodpecker) - Before seeding so the webhook is installed ciActivated := s.activateCI(ctx, result) // 8. Seed repository with template templateSeeded := s.seedTemplate(ctx, req, result) // 9. Provision database and cache s.provisionResources(ctx, result) // 10. Provision Citadel log environment s.provisionCitadel(ctx, result) // 11. Create initial K8s deployment (before triggering CI build) // This ensures the deployment exists for `kubectl set image` in CI pipeline if templateSeeded { s.createInitialDeployment(ctx, req, result) } // 12. Trigger initial CI build if both CI and template are ready if ciActivated && templateSeeded && s.ciProvider != nil { pipelineNum, err := s.ciProvider.TriggerBuild(ctx, result.GitRepoOwner, result.GitRepoName, "main") if err != nil { log.Warn("failed to trigger initial build", logging.FieldError, err) } else { log.Info("initial build triggered", "pipeline", pipelineNum) } } log.Info("project created successfully", logging.FieldProjectID, req.Name, "git_repo", result.CloneSSH, "domain", result.Domain, ) return result, nil } func (s *ProjectInfraService) generateSlug(ctx context.Context, name string) (string, error) { if s.slugGenerator != nil { slug, err := s.slugGenerator.Generate(ctx) if err != nil { return "", fmt.Errorf("failed to generate slug: %w", err) } return slug, nil } // Fallback: use first 8 chars of name if no slug generator slug := name if len(slug) > 8 { slug = slug[:8] } return slug, nil } func (s *ProjectInfraService) createProjectInDB(ctx context.Context, projectID string, req CreateProjectRequest, slug string) error { now := time.Now() _, err := s.db.ExecContext(ctx, ` INSERT INTO projects (id, name, description, slug, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5) ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, slug = COALESCE(projects.slug, EXCLUDED.slug), updated_at = EXCLUDED.updated_at `, projectID, req.Name, req.Description, slug, now) if err != nil { return fmt.Errorf("failed to create project in database: %w", err) } return nil } func (s *ProjectInfraService) createGitRepo(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult, projectID string) { log := logging.FromContext(ctx).WithService("project_infra") if s.gitRepo == nil { result.NextSteps = append(result.NextSteps, "Git repository service not configured") return } repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private) if err != nil { // Check if repo already exists - if so, fetch it instead if strings.Contains(err.Error(), "already exists") { log.Info("git repo already exists, fetching existing", "name", req.Name) existingRepo, getErr := s.gitRepo.GetRepo(ctx, s.defaultGitOwner, req.Name) if getErr != nil { log.Error("failed to get existing git repo", logging.FieldError, getErr) result.NextSteps = append(result.NextSteps, "Git repo exists but couldn't fetch details") return } repo = existingRepo } else { log.Error("failed to create git repo", logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create") return } } result.GitRepoOwner = repo.Owner result.GitRepoName = repo.Name result.CloneSSH = repo.CloneSSH result.CloneHTTP = repo.CloneHTTP result.HTMLURL = repo.HTMLURL // Update database with git info _, err = s.db.ExecContext(ctx, ` UPDATE projects SET git_repo_owner = $1, git_repo_name = $2, git_clone_ssh = $3, git_clone_http = $4, git_html_url = $5, updated_at = $6 WHERE id = $7 `, repo.Owner, repo.Name, repo.CloneSSH, repo.CloneHTTP, repo.HTMLURL, time.Now(), projectID) if err != nil { log.Warn("failed to update project with git info", logging.FieldError, err, logging.FieldProjectID, projectID) result.NextSteps = append(result.NextSteps, "Git repo created but metadata not persisted - re-run create to sync") } } func (s *ProjectInfraService) createPrimaryDNS(ctx context.Context, slug, autoDomain, projectID string, result *CreateProjectResult) { log := logging.FromContext(ctx).WithService("project_infra") if s.dns == nil { result.NextSteps = append(result.NextSteps, "DNS service not configured") return } dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", Name: slug, Content: s.clusterIP, TTL: 1, Proxied: false, }) if err != nil { log.Warn("failed to create DNS record", logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+autoDomain+" → "+s.clusterIP) return } // Store in project_domains table if s.domainRepo != nil { pd := &domain.ProjectDomain{ ProjectID: projectID, Domain: autoDomain, Type: domain.DomainTypePrimaryAuto, DNSRecordID: dnsRecord.ID, DNSRecordType: "A", Verified: true, } if err := s.domainRepo.Create(ctx, pd); err != nil { log.Warn("failed to store primary domain", logging.FieldError, err) result.NextSteps = append(result.NextSteps, "DNS created but domain metadata not persisted") } else { result.Domains = append(result.Domains, pd) } } // Also update legacy domain column for backward compatibility _, err = s.db.ExecContext(ctx, ` UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3 `, result.Domain, time.Now(), projectID) if err != nil { log.Warn("failed to update project with domain", logging.FieldError, err, logging.FieldProjectID, projectID) // Not adding to NextSteps - legacy column, domain still works via project_domains table } } func (s *ProjectInfraService) createCustomDNS(ctx context.Context, req CreateProjectRequest, projectID string, result *CreateProjectResult) { if req.CustomSubdomain == "" || s.dns == nil || s.domainRepo == nil { return } log := logging.FromContext(ctx).WithService("project_infra") customDomain := req.CustomSubdomain + "." + s.defaultDomain dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", Name: req.CustomSubdomain, Content: s.clusterIP, TTL: 1, Proxied: false, }) if err != nil { log.Warn("failed to create custom DNS record", logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Create custom DNS manually: "+customDomain+" → "+s.clusterIP) return } pd := &domain.ProjectDomain{ ProjectID: projectID, Domain: customDomain, Type: domain.DomainTypePrimaryCustom, DNSRecordID: dnsRecord.ID, DNSRecordType: "A", Verified: true, } if err := s.domainRepo.Create(ctx, pd); err != nil { log.Warn("failed to store custom domain", logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Custom DNS created but domain metadata not persisted") } else { result.Domains = append(result.Domains, pd) // Custom domain becomes the primary for display result.Domain = customDomain result.URL = "https://" + customDomain } } // createDocsDNS creates DNS record for docs.{slug}.{defaultDomain}. // This enables Slate API documentation to be served at docs.{domain}. func (s *ProjectInfraService) createDocsDNS(ctx context.Context, slug, projectID string, result *CreateProjectResult) { if s.dns == nil { return // DNS not configured, skip silently (not critical) } log := logging.FromContext(ctx).WithService("project_infra") docsSubdomain := "docs." + slug docsDomain := docsSubdomain + "." + s.defaultDomain dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", Name: docsSubdomain, Content: s.clusterIP, TTL: 1, Proxied: false, }) if err != nil { log.Warn("failed to create docs DNS record", logging.FieldError, err, "domain", docsDomain) result.NextSteps = append(result.NextSteps, "Create docs DNS manually: "+docsDomain+" → "+s.clusterIP) return } // Store in project_domains table if s.domainRepo != nil { pd := &domain.ProjectDomain{ ProjectID: projectID, Domain: docsDomain, Type: domain.DomainTypeAlias, // docs subdomain is an alias DNSRecordID: dnsRecord.ID, DNSRecordType: "A", Verified: true, } if err := s.domainRepo.Create(ctx, pd); err != nil { log.Warn("failed to store docs domain", logging.FieldError, err) } else { result.Domains = append(result.Domains, pd) } } result.DocsURL = "https://" + docsDomain log.Info("docs DNS created", "domain", docsDomain) } func (s *ProjectInfraService) activateCI(ctx context.Context, result *CreateProjectResult) bool { log := logging.FromContext(ctx).WithService("project_infra") if s.ciProvider == nil { result.NextSteps = append(result.NextSteps, "CI provider not configured") return false } if result.GitRepoOwner == "" { return false } ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName) if err != nil { log.Warn("failed to activate CI", logging.FieldError, err) result.NextSteps = append(result.NextSteps, fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName), ) return false } log.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID) return true } func (s *ProjectInfraService) seedTemplate(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) bool { log := logging.FromContext(ctx).WithService("project_infra") if s.templateProvider == nil { result.NextSteps = append(result.NextSteps, "Template provider not configured") return false } if result.GitRepoOwner == "" { return false } templateName := req.Template if templateName == "" { templateName = "skeleton" // Default to composable monorepo skeleton } // Build Go module path for the project (use actual git host, not github.com) goModule := fmt.Sprintf("git.threesix.ai/%s/%s", result.GitRepoOwner, result.GitRepoName) vars := map[string]string{ "PROJECT_NAME": req.Name, "DOMAIN": result.Domain, "GIT_URL": result.CloneHTTP, "DESCRIPTION": req.Description, "GO_MODULE": goModule, } err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars) if err != nil { log.Warn("failed to seed repo with template", logging.FieldError, err, "template", templateName) result.NextSteps = append(result.NextSteps, fmt.Sprintf("Add template files manually (template: %s)", templateName), ) return false } log.Info("repo seeded with template", "template", templateName) return true } // provisionResources provisions database and cache for a project. // Credentials are stored in the credential store for injection into deployments. // If credential storage fails after provisioning, the resources are rolled back to prevent orphans. // This function is idempotent - it skips resources that already exist. func (s *ProjectInfraService) provisionResources(ctx context.Context, result *CreateProjectResult) { log := logging.FromContext(ctx).WithService("project_infra") projectID := result.ProjectID // Provision database (idempotent) if s.dbProvisioner != nil { // Check if already provisioned existing, _ := s.dbProvisioner.GetProjectDatabase(ctx, projectID) if existing != nil { log.Info("database already provisioned, skipping", logging.FieldProjectID, projectID) } else { dbCreds, err := s.dbProvisioner.CreateProjectDatabase(ctx, projectID) if err != nil { log.Error("failed to provision database", logging.FieldProjectID, projectID, logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Database provisioning failed - contact admin") } else if s.credentialStore != nil { // Store credentials - rollback on failure to prevent orphaned database var storeErr error if err := s.storeCredential(ctx, projectID, "database", "DATABASE_URL", dbCreds.URL); err != nil { storeErr = err log.Error("failed to store DATABASE_URL", logging.FieldProjectID, projectID, logging.FieldError, err) } if err := s.storeCredential(ctx, projectID, "database", "DATABASE_URL_STAGING", dbCreds.URLStaging); err != nil { storeErr = err log.Error("failed to store DATABASE_URL_STAGING", logging.FieldProjectID, projectID, logging.FieldError, err) } // Rollback database if credential storage failed if storeErr != nil { log.Warn("rolling back database due to credential storage failure", logging.FieldProjectID, projectID) if rollbackErr := s.dbProvisioner.DeleteProjectDatabase(ctx, projectID); rollbackErr != nil { log.Error("failed to rollback database", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr) result.NextSteps = append(result.NextSteps, "Database created but credentials not stored - manual cleanup required") } else { result.NextSteps = append(result.NextSteps, "Database provisioning rolled back due to credential storage failure") } } else { log.Info("database provisioned", logging.FieldProjectID, projectID, "database", dbCreds.DatabaseName) } } } } // Provision cache (idempotent) if s.cacheProvisioner != nil { // Check if already provisioned existing, _ := s.cacheProvisioner.GetProjectCache(ctx, projectID) if existing != nil { log.Info("cache already provisioned, skipping", logging.FieldProjectID, projectID) } else { cacheCreds, err := s.cacheProvisioner.CreateProjectCache(ctx, projectID) if err != nil { log.Error("failed to provision cache", logging.FieldProjectID, projectID, logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Cache provisioning failed - contact admin") } else if s.credentialStore != nil { // Store credentials - rollback on failure to prevent orphaned cache var storeErr error if err := s.storeCredential(ctx, projectID, "cache", "REDIS_URL", cacheCreds.URL); err != nil { storeErr = err log.Error("failed to store REDIS_URL", logging.FieldProjectID, projectID, logging.FieldError, err) } if err := s.storeCredential(ctx, projectID, "cache", "REDIS_URL_STAGING", cacheCreds.URLStaging); err != nil { storeErr = err log.Error("failed to store REDIS_URL_STAGING", logging.FieldProjectID, projectID, logging.FieldError, err) } if err := s.storeCredential(ctx, projectID, "cache", "REDIS_PREFIX", cacheCreds.Prefix); err != nil { storeErr = err log.Error("failed to store REDIS_PREFIX", logging.FieldProjectID, projectID, logging.FieldError, err) } // Rollback cache if credential storage failed if storeErr != nil { log.Warn("rolling back cache due to credential storage failure", logging.FieldProjectID, projectID) if rollbackErr := s.cacheProvisioner.DeleteProjectCache(ctx, projectID, true); rollbackErr != nil { log.Error("failed to rollback cache", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr) result.NextSteps = append(result.NextSteps, "Cache created but credentials not stored - manual cleanup required") } else { result.NextSteps = append(result.NextSteps, "Cache provisioning rolled back due to credential storage failure") } } else { log.Info("cache provisioned", logging.FieldProjectID, projectID, "prefix", cacheCreds.Prefix) } } } } // Provision storage (idempotent) if s.storageProvisioner != nil { existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID) if existing != nil { log.Info("storage already provisioned, skipping", logging.FieldProjectID, projectID) } else { storageCreds, err := s.storageProvisioner.CreateProjectBucket(ctx, projectID) if err != nil { log.Error("failed to provision storage", logging.FieldProjectID, projectID, logging.FieldError, err) result.NextSteps = append(result.NextSteps, "Storage provisioning failed - contact admin") } else if s.credentialStore != nil { // Store credentials - rollback on failure to prevent orphaned bucket var storeErr error if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryStorage, domain.CredKeyGCSBucket, storageCreds.BucketName); err != nil { storeErr = err log.Error("failed to store GCS_BUCKET", logging.FieldProjectID, projectID, logging.FieldError, err) } if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryStorage, domain.CredKeyGCSServiceAccountJSON, storageCreds.ServiceAccountJSON); err != nil { storeErr = err log.Error("failed to store GCS_SERVICE_ACCOUNT_JSON", logging.FieldProjectID, projectID, logging.FieldError, err) } // Rollback storage if credential storage failed if storeErr != nil { log.Warn("rolling back storage due to credential storage failure", logging.FieldProjectID, projectID) if rollbackErr := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); rollbackErr != nil { log.Error("failed to rollback storage", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr) result.NextSteps = append(result.NextSteps, "Storage created but credentials not stored - manual cleanup required") } else { result.NextSteps = append(result.NextSteps, "Storage provisioning rolled back due to credential storage failure") } } else { log.Info("storage provisioned", logging.FieldProjectID, projectID, "bucket", storageCreds.BucketName) } } } } } // provisionCitadel creates a Citadel log environment for the project. // The tenant_id is stored on the project record and used by the agent DaemonSet // to route container logs to the correct Citadel environment. func (s *ProjectInfraService) provisionCitadel(ctx context.Context, result *CreateProjectResult) { if s.citadelClient == nil { return // Citadel not configured, skip silently } log := logging.FromContext(ctx).WithService("project_infra") envName := result.Slug // Use project slug as Citadel environment name env, err := s.citadelClient.CreateEnvironment(ctx, envName) if err != nil { log.Warn("failed to create citadel environment", logging.FieldError, err, logging.FieldProjectID, result.ProjectID, ) result.NextSteps = append(result.NextSteps, "Citadel log environment not created - create manually with: citadel env create "+envName) return } // Store tenant_id on project record and result (for downstream label injection) result.CitadelTenantID = env.TenantID _, err = s.db.ExecContext(ctx, ` UPDATE projects SET citadel_tenant_id = $1, updated_at = $2 WHERE id = $3 `, env.TenantID, time.Now(), result.ProjectID) if err != nil { log.Warn("failed to store citadel tenant_id", logging.FieldError, err, logging.FieldProjectID, result.ProjectID, ) result.NextSteps = append(result.NextSteps, "Citadel environment created but tenant_id not persisted") } log.Info("citadel environment provisioned", logging.FieldProjectID, result.ProjectID, "citadel_tenant_id", env.TenantID, "citadel_env_name", envName, ) } // deleteCitadelEnvironment removes the Citadel log environment for a project. func (s *ProjectInfraService) deleteCitadelEnvironment(ctx context.Context, projectID string) { if s.citadelClient == nil { return } log := logging.FromContext(ctx).WithService("project_infra") // Look up the citadel_tenant_id from the project record var tenantID sql.NullString err := s.db.QueryRowContext(ctx, `SELECT citadel_tenant_id FROM projects WHERE id = $1`, projectID).Scan(&tenantID) if err != nil || !tenantID.Valid || tenantID.String == "" { log.Debug("no citadel tenant_id found for project, skipping cleanup", logging.FieldProjectID, projectID) return } if err := s.citadelClient.DeleteEnvironment(ctx, tenantID.String); err != nil { log.Warn("failed to delete citadel environment", logging.FieldError, err, logging.FieldProjectID, projectID, "citadel_tenant_id", tenantID.String, ) return } log.Info("citadel environment deleted", logging.FieldProjectID, projectID, "citadel_tenant_id", tenantID.String) } // citadelLabels builds k8s labels for Citadel agent log routing. // The agent DaemonSet uses these labels to determine which Citadel environment // to ship container logs to. Returns nil if no tenant ID is available. func citadelLabels(envName, serviceName, tenantID string) map[string]string { if tenantID == "" { return nil } return map[string]string{ "citadel.io/environment": envName, "citadel.io/service": serviceName, } } // storeCredential stores a project-scoped credential in the credential store. // Keys are prefixed with the project ID for isolation (e.g., "myproject:DATABASE_URL"). func (s *ProjectInfraService) storeCredential(ctx context.Context, projectID, category, key, value string) error { scopedKey := projectID + ":" + key return s.credentialStore.Set(ctx, domain.Credential{ Key: scopedKey, Value: value, Category: category, }) } // createInitialDeployment creates the initial K8s deployment for a project. // This is called after template seeding to ensure the deployment exists before // the CI pipeline runs `kubectl set image`. The deployment will be in ImagePullBackOff // until the first CI build completes and pushes the image. // // For monorepo (skeleton) projects, no root deployment is created - components // create their own deployments via ComponentService.AddComponent(). func (s *ProjectInfraService) createInitialDeployment(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) { log := logging.FromContext(ctx).WithService("project_infra") // Skip root deployment for monorepo (skeleton) projects. // Skeleton projects have no root Dockerfile - components create their own deployments. // Note: empty template defaults to "skeleton", so check for both. if req.Template == "skeleton" || req.Template == "" { log.Info("skipping root deployment for monorepo project", logging.FieldProjectID, req.Name, "template", req.Template, ) return } if s.deployer == nil { result.NextSteps = append(result.NextSteps, "Deployer not configured - run POST /projects/{id}/deploy after first build") return } // Build the expected image name that CI will push to // Format: {registryURL}/{projectName}:latest imageName := fmt.Sprintf("%s/%s:latest", s.registryURL, req.Name) // Determine port based on template port := templateDefaultPort(req.Template) spec := domain.DeploySpec{ ProjectName: req.Name, Image: imageName, Domain: result.Domain, Port: port, Replicas: 1, ExtraLabels: citadelLabels(result.Slug, req.Name, result.CitadelTenantID), } err := s.deployer.Deploy(ctx, spec) if err != nil { log.Warn("failed to create initial deployment", logging.FieldError, err, logging.FieldProjectID, req.Name) result.NextSteps = append(result.NextSteps, "Initial deployment failed - run POST /projects/{id}/deploy after first build completes", ) return } log.Info("initial deployment created", logging.FieldProjectID, req.Name, "image", imageName, "domain", result.Domain, "note", "deployment will be pending until first CI build completes", ) // Update database with deployment info _, err = s.db.ExecContext(ctx, ` UPDATE projects SET deployment_image = $1, deployment_status = $2, deployment_replicas = $3, updated_at = $4 WHERE id = $5 `, imageName, "pending", 1, time.Now(), req.Name) if err != nil { log.Warn("failed to update project with deployment info", logging.FieldError, err, logging.FieldProjectID, req.Name) result.NextSteps = append(result.NextSteps, "Deployment created but status not persisted - status may show stale") } } // templateDefaultPort returns the default port for a template. // Templates can override this by specifying a custom port in template metadata (future enhancement). var templateDefaultPorts = map[string]int{ "astro-landing": 80, // nginx static server "default": 80, // nginx static server "go-api": 8080, // Go API server "skeleton": 8080, // monorepo skeleton (Go services default) } func templateDefaultPort(templateName string) int { if port, ok := templateDefaultPorts[templateName]; ok { return port } return 80 // Default to nginx port for static sites } // GetStatus returns the current status of a project. func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (*ProjectStatus, error) { var status ProjectStatus err := s.db.QueryRowContext(ctx, ` SELECT id, name, COALESCE(description, ''), COALESCE(slug, ''), COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''), COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''), COALESCE(domain, ''), COALESCE(custom_domain, ''), COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'), COALESCE(deployment_replicas, 1) FROM projects WHERE id = $1 `, projectID).Scan( &status.ProjectID, &status.Name, &status.Description, &status.Slug, &status.GitRepoOwner, &status.GitRepoName, &status.CloneSSH, &status.CloneHTTP, &status.HTMLURL, &status.Domain, &status.CustomDomain, &status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) } // Load all domains from project_domains table if s.domainRepo != nil { domains, err := s.domainRepo.ListByProject(ctx, projectID) if err != nil { log := logging.FromContext(ctx).WithService("project_infra") log.Warn("failed to load project domains", logging.FieldError, err, logging.FieldProjectID, projectID) } else { status.Domains = domains // Set primary domain from domains list if not set if status.Domain == "" && len(domains) > 0 { // Prefer custom over auto for _, d := range domains { if d.Type == domain.DomainTypePrimaryCustom { status.Domain = d.Domain break } } if status.Domain == "" { status.Domain = domains[0].Domain } } } } if status.Domain != "" { status.URL = "https://" + status.Domain } // Get live deployment status if deployer is available if s.deployer != nil { deployStatus, err := s.deployer.GetStatus(ctx, projectID) if err == nil && deployStatus != nil { status.DeploymentStatus = string(deployStatus.Status) status.ReadyReplicas = deployStatus.ReadyReplicas if deployStatus.URL != "" { status.URL = deployStatus.URL } } } // Check if site is live (HTTP health check) if status.URL != "" { live, errMsg := checkSiteHealth(ctx, status.URL) status.SiteLive = live status.SiteError = errMsg } return &status, nil } // ListProjects returns all projects. func (s *ProjectInfraService) ListProjects(ctx context.Context) ([]*ProjectStatus, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, COALESCE(description, ''), COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''), COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''), COALESCE(domain, ''), COALESCE(custom_domain, ''), COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'), COALESCE(deployment_replicas, 1) FROM projects ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("failed to list projects: %w", err) } defer func() { _ = rows.Close() }() var projects []*ProjectStatus for rows.Next() { var status ProjectStatus err := rows.Scan( &status.ProjectID, &status.Name, &status.Description, &status.GitRepoOwner, &status.GitRepoName, &status.CloneSSH, &status.CloneHTTP, &status.HTMLURL, &status.Domain, &status.CustomDomain, &status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas, ) if err != nil { continue } if status.Domain != "" { status.URL = "https://" + status.Domain } projects = append(projects, &status) } return projects, nil } // DeleteProject removes a project and its associated resources. func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID string) error { log := logging.FromContext(ctx).WithService("project_infra") log.Info("deleting project", logging.FieldProjectID, projectID) // Get project info first status, err := s.GetStatus(ctx, projectID) if err != nil { return err } // 1. Undeploy all K8s resources (always attempt — component deployments // may exist even when the main deployment status is "none") if s.deployer != nil { if err := s.deployer.UndeployAll(ctx, projectID); err != nil { log.Warn("failed to undeploy", logging.FieldError, err) } } // 2. Delete provisioned database if s.dbProvisioner != nil { if err := s.dbProvisioner.DeleteProjectDatabase(ctx, projectID); err != nil { log.Warn("failed to delete project database", logging.FieldError, err) } } // 3. Delete provisioned cache (and purge keys) if s.cacheProvisioner != nil { if err := s.cacheProvisioner.DeleteProjectCache(ctx, projectID, true); err != nil { log.Warn("failed to delete project cache", logging.FieldError, err) } } // 4. Delete provisioned storage (GCS bucket) if s.storageProvisioner != nil { if err := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); err != nil { log.Warn("failed to delete project storage", logging.FieldError, err) } } // 5b. Delete Citadel log environment s.deleteCitadelEnvironment(ctx, projectID) // 6. Delete container images from registry if s.registryProvider != nil { if err := s.registryProvider.DeleteProjectRepositories(ctx, projectID); err != nil { log.Warn("failed to delete project registry images", logging.FieldError, err) } } // 7. Delete all DNS records for project domains s.deleteDNSRecords(ctx, status) // 8. Delete all project_domains entries (CASCADE should handle this, but be explicit) if s.domainRepo != nil { if err := s.domainRepo.DeleteByProject(ctx, projectID); err != nil { log.Warn("failed to delete project domains", logging.FieldError, err) } } // 9. Delete git repo (optional - might want to keep it) // Skipping git repo deletion for safety // 10. Delete from database _, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID) if err != nil { return fmt.Errorf("failed to delete project from database: %w", err) } log.Info("project deleted", logging.FieldProjectID, projectID) return nil } func (s *ProjectInfraService) deleteDNSRecords(ctx context.Context, status *ProjectStatus) { if s.dns == nil { return } log := logging.FromContext(ctx).WithService("project_infra") // Delete DNS records for all domains in project_domains table if len(status.Domains) > 0 { for _, pd := range status.Domains { if pd.DNSRecordID != "" { if err := s.dns.DeleteRecord(ctx, pd.DNSRecordID); err != nil { log.Warn("failed to delete DNS record by ID", logging.FieldError, err, "domain", pd.Domain, "record_id", pd.DNSRecordID) } } else { subdomain := domain.ExtractSubdomain(pd.Domain, s.defaultDomain) if subdomain != "" { if err := s.dns.DeleteRecordByName(ctx, pd.DNSRecordType, subdomain); err != nil { log.Warn("failed to delete DNS record by name", logging.FieldError, err, "domain", pd.Domain) } } } } } else if status.Domain != "" { // Fallback for legacy projects without project_domains entries subdomain := status.Name if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil { log.Warn("failed to delete DNS record", logging.FieldError, err) } } } // ListTemplates returns available project templates. func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) { if s.templateProvider == nil { return nil, fmt.Errorf("template provider not configured") } return s.templateProvider.ListTemplates(ctx) } // GetTemplate returns info about a specific template. func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) { if s.templateProvider == nil { return nil, fmt.Errorf("template provider not configured") } return s.templateProvider.GetTemplate(ctx, name) } // ListComponentTemplates returns available component templates, optionally filtered by type. func (s *ProjectInfraService) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) { if s.templateProvider == nil { return nil, fmt.Errorf("template provider not configured") } return s.templateProvider.ListComponentTemplates(ctx, componentType) } // CleanupTestProjectsRequest contains parameters for cleaning up test projects. type CleanupTestProjectsRequest struct { Patterns []string // Name patterns to match (e.g., "tree-test-*", "landing-test-*") OlderThanHrs int // Only delete projects older than this many hours DryRun bool // If true, don't actually delete, just return what would be deleted } // CleanupTestProjectsResult contains the result of a cleanup operation. type CleanupTestProjectsResult struct { Deleted []string // Names of deleted projects Count int // Number of projects deleted DryRun bool // Whether this was a dry run } // CleanupTestProjects deletes test projects matching the given patterns that are older than the specified age. // This is useful for cleaning up orphaned test projects created by cookbook scripts. func (s *ProjectInfraService) CleanupTestProjects(ctx context.Context, req CleanupTestProjectsRequest) (*CleanupTestProjectsResult, error) { if len(req.Patterns) == 0 { return nil, fmt.Errorf("at least one pattern is required") } log := logging.FromContext(ctx).WithService("project_infra") // Calculate cutoff time cutoff := time.Now().Add(-time.Duration(req.OlderThanHrs) * time.Hour) log.Info("cleaning up test projects", "patterns", req.Patterns, "older_than_hours", req.OlderThanHrs, "cutoff", cutoff, "dry_run", req.DryRun, ) // Find matching projects projects, err := s.listProjectsByPatternOlderThan(ctx, req.Patterns, cutoff) if err != nil { return nil, fmt.Errorf("failed to list matching projects: %w", err) } result := &CleanupTestProjectsResult{ Deleted: make([]string, 0, len(projects)), DryRun: req.DryRun, } for _, projectID := range projects { if req.DryRun { result.Deleted = append(result.Deleted, projectID) continue } // Actually delete the project if err := s.DeleteProject(ctx, projectID); err != nil { log.Warn("failed to delete project during cleanup", logging.FieldProjectID, projectID, logging.FieldError, err, ) // Continue with other projects even if one fails continue } result.Deleted = append(result.Deleted, projectID) log.Info("deleted test project", logging.FieldProjectID, projectID) } result.Count = len(result.Deleted) log.Info("test project cleanup complete", "deleted_count", result.Count, "dry_run", req.DryRun, ) return result, nil } // listProjectsByPatternOlderThan returns project IDs matching any of the given patterns // that were created before the cutoff time. // Patterns support SQL LIKE wildcards: % (any characters), _ (single character). // For convenience, * is converted to % for shell-style glob patterns. func (s *ProjectInfraService) listProjectsByPatternOlderThan(ctx context.Context, patterns []string, cutoff time.Time) ([]string, error) { // Convert shell-style globs to SQL LIKE patterns likePatterns := make([]string, len(patterns)) for i, p := range patterns { // Replace * with % for SQL LIKE likePatterns[i] = strings.ReplaceAll(p, "*", "%") } // Build query with OR conditions for each pattern // Using parameterized query to prevent SQL injection var queryBuilder strings.Builder queryBuilder.WriteString(`SELECT id FROM projects WHERE created_at < $1 AND (`) args := []any{cutoff} for i, pattern := range likePatterns { if i > 0 { queryBuilder.WriteString(" OR ") } fmt.Fprintf(&queryBuilder, "name LIKE $%d", i+2) args = append(args, pattern) } queryBuilder.WriteString(`) ORDER BY created_at ASC`) rows, err := s.db.QueryContext(ctx, queryBuilder.String(), args...) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } defer func() { _ = rows.Close() }() var projectIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { continue } projectIDs = append(projectIDs, id) } return projectIDs, rows.Err() }