rdev/internal/service/project_infra_crud.go
jordan 863dfd3214
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: skip root deployment for empty template (defaults to skeleton)
When req.Template is empty, it defaults to 'skeleton' but the check
in createInitialDeployment only matched 'skeleton' explicitly, not
empty string. This caused a broken deployment to be created for
monorepo projects with a non-existent image.

Root cause: slackpath-5 creates project with empty template, which
defaults to skeleton, but createInitialDeployment was still creating
a root deployment that references registry.threesix.ai/{project}:latest
which never gets built (skeleton has no root Dockerfile).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 19:32:19 -07:00

912 lines
32 KiB
Go

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. 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)
}
// 11. 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)
}
}
}
}
}
// 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,
}
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 if deployed
if s.deployer != nil && status.DeploymentStatus != "none" {
if err := s.deployer.Undeploy(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 all DNS records for project domains
s.deleteDNSRecords(ctx, status)
// 5. 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)
}
}
// 6. Delete git repo (optional - might want to keep it)
// Skipping git repo deletion for safety
// 7. 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()
}