// Package service provides business logic services. package service import ( "context" "database/sql" "errors" "fmt" "log/slog" "regexp" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // projectNameRegex validates project names for DNS and K8s compatibility. // Must be lowercase, start with a letter, contain only letters, numbers, and dashes. var projectNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // reservedProjectNames are names that cannot be used for projects. var reservedProjectNames = map[string]bool{ "www": true, "api": true, "git": true, "ci": true, "registry": true, "admin": true, "root": true, "rdev": true, "pantheon": true, } // ValidateProjectName validates that a project name is safe for use as // a DNS subdomain, K8s resource name, and git repository name. func ValidateProjectName(name string) error { if name == "" { return errors.New("project name cannot be empty") } if len(name) > 63 { return errors.New("project name too long (max 63 characters)") } if !projectNameRegex.MatchString(name) { return errors.New("project name must be lowercase, start with a letter, and contain only letters, numbers, and dashes") } if reservedProjectNames[name] { return fmt.Errorf("'%s' is a reserved name", name) } return nil } // ProjectInfraService orchestrates project infrastructure operations. // It coordinates git repo creation, DNS, and deployment. type ProjectInfraService struct { db *sql.DB gitRepo port.GitRepository dns port.DNSProvider deployer port.Deployer logger *slog.Logger // Config defaultGitOwner string defaultDomain string clusterIP string } // ProjectInfraConfig configures the project infrastructure service. type ProjectInfraConfig struct { DefaultGitOwner string // e.g., "threesix" DefaultDomain string // e.g., "threesix.ai" ClusterIP string // e.g., "208.122.204.172" Logger *slog.Logger } // NewProjectInfraService creates a new project infrastructure service. func NewProjectInfraService( db *sql.DB, gitRepo port.GitRepository, dns port.DNSProvider, deployer port.Deployer, cfg ProjectInfraConfig, ) *ProjectInfraService { logger := cfg.Logger if logger == nil { logger = slog.Default() } return &ProjectInfraService{ db: db, gitRepo: gitRepo, dns: dns, deployer: deployer, logger: logger, defaultGitOwner: cfg.DefaultGitOwner, defaultDomain: cfg.DefaultDomain, clusterIP: cfg.ClusterIP, } } // CreateProjectRequest contains parameters for creating a new project. type CreateProjectRequest struct { Name string Description string Private bool } // CreateProjectResult contains the result of project creation. type CreateProjectResult struct { ProjectID string Name string Description string // Git info GitRepoOwner string GitRepoName string CloneSSH string CloneHTTP string HTMLURL string // Domain info Domain string URL string // Next steps NextSteps []string } // 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) { // Validate project name first if err := ValidateProjectName(req.Name); err != nil { return nil, fmt.Errorf("invalid project name: %w", err) } s.logger.Info("creating project", "name", req.Name) // 1. Create project in database projectID := req.Name // Use name as ID for simplicity now := time.Now() _, err := s.db.ExecContext(ctx, ` INSERT INTO projects (id, name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, updated_at = EXCLUDED.updated_at `, projectID, req.Name, req.Description, now, now) if err != nil { return nil, fmt.Errorf("failed to create project in database: %w", err) } result := &CreateProjectResult{ ProjectID: projectID, Name: req.Name, Description: req.Description, Domain: req.Name + "." + s.defaultDomain, } result.URL = "https://" + result.Domain // 2. Create git repository if s.gitRepo != nil { repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private) if err != nil { s.logger.Error("failed to create git repo", "error", err) result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create") } else { 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 { s.logger.Error("failed to update project with git info", "error", err, "project", projectID) // Continue - the git repo was created, we just failed to record it } } } else { result.NextSteps = append(result.NextSteps, "Git repository service not configured") } // 3. Create DNS record if s.dns != nil { _, err := s.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", Name: req.Name, Content: s.clusterIP, TTL: 1, Proxied: false, }) if err != nil { s.logger.Warn("failed to create DNS record", "error", err) result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+req.Name+"."+s.defaultDomain+" → "+s.clusterIP) } else { // Update database with domain _, err = s.db.ExecContext(ctx, ` UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3 `, result.Domain, time.Now(), projectID) if err != nil { s.logger.Error("failed to update project with domain", "error", err, "project", projectID) // Continue - the DNS was created, we just failed to record it } } } else { result.NextSteps = append(result.NextSteps, "DNS service not configured") } // 4. Add next steps for Woodpecker activation if result.HTMLURL != "" { result.NextSteps = append(result.NextSteps, fmt.Sprintf("Activate in Woodpecker: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, s.defaultGitOwner, req.Name), "Add .woodpecker.yml to your repo for CI/CD", ) } s.logger.Info("project created successfully", "project", req.Name, "git_repo", result.CloneSSH, "domain", result.Domain, ) return result, nil } // GetProjectStatus returns the current status of a project. type ProjectStatus struct { ProjectID string Name string Description string // Git GitRepoOwner string GitRepoName string CloneSSH string CloneHTTP string HTMLURL string // Domain Domain string CustomDomain string URL string // Deployment DeploymentImage string DeploymentStatus string DeploymentReplicas int ReadyReplicas int } // 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(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.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("project not found: %s", projectID) } if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) } 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 } } } 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 { s.logger.Info("deleting project", "project", 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 { s.logger.Warn("failed to undeploy", "error", err) } } // 2. Delete DNS record if s.dns != nil && status.Domain != "" { subdomain := status.Name if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil { s.logger.Warn("failed to delete DNS record", "error", err) } } // 3. Delete git repo (optional - might want to keep it) // Skipping git repo deletion for safety // 4. 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) } s.logger.Info("project deleted", "project", projectID) return nil }