rdev/internal/service/project_infra_crud.go
jordan 34e72687e6 feat: Complete automation gaps for repeatable project deployments
- Initial K8s deployment auto-creation during project creation
- DNS record upsert support (create or update existing records)
- Ingress host management for domain aliases (AddIngressHost/RemoveIngressHost)
- Woodpecker deployer RBAC manifest for CI deploy steps
- Single-commit template seeding via Gitea bulk file API

Closes automation gaps exposed during www.threesix.ai launch:
- Projects now auto-create K8s Deployment/Service/Ingress on creation
- Domain aliases automatically update both DNS and K8s ingress
- CI deploy steps work without manual RBAC setup
- Template seeding triggers only one CI pipeline (not per-file)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:18:31 -07:00

567 lines
18 KiB
Go

package service
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"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) {
// 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)
}
}
s.logger.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. Activate CI (Woodpecker) - Before seeding so the webhook is installed
ciActivated := s.activateCI(ctx, result)
// 7. Seed repository with template
templateSeeded := s.seedTemplate(ctx, req, result)
// 8. 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)
}
// 9. 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 {
s.logger.Warn("failed to trigger initial build", "error", err)
} else {
s.logger.Info("initial build triggered", "pipeline", pipelineNum)
}
}
s.logger.Info("project created successfully",
"project", 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) {
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 {
s.logger.Error("failed to create git repo", "error", 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 {
s.logger.Error("failed to update project with git info", "error", err, "project", projectID)
}
}
func (s *ProjectInfraService) createPrimaryDNS(ctx context.Context, slug, autoDomain, projectID string, result *CreateProjectResult) {
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 {
s.logger.Warn("failed to create DNS record", "error", 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 {
s.logger.Error("failed to store primary domain", "error", err)
} 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 {
s.logger.Error("failed to update project with domain", "error", err, "project", projectID)
}
}
func (s *ProjectInfraService) createCustomDNS(ctx context.Context, req CreateProjectRequest, projectID string, result *CreateProjectResult) {
if req.CustomSubdomain == "" || s.dns == nil || s.domainRepo == nil {
return
}
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 {
s.logger.Warn("failed to create custom DNS record", "error", 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 {
s.logger.Error("failed to store custom domain", "error", err)
} else {
result.Domains = append(result.Domains, pd)
// Custom domain becomes the primary for display
result.Domain = customDomain
result.URL = "https://" + customDomain
}
}
func (s *ProjectInfraService) activateCI(ctx context.Context, result *CreateProjectResult) bool {
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 {
s.logger.Warn("failed to activate CI", "error", 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
}
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
return true
}
func (s *ProjectInfraService) seedTemplate(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) bool {
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 = "default"
}
vars := map[string]string{
"PROJECT_NAME": req.Name,
"DOMAIN": result.Domain,
"GIT_URL": result.CloneHTTP,
}
err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars)
if err != nil {
s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Add template files manually (template: %s)", templateName),
)
return false
}
s.logger.Info("repo seeded with template", "template", templateName)
return true
}
// 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.
func (s *ProjectInfraService) createInitialDeployment(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) {
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 {
s.logger.Warn("failed to create initial deployment", "error", err, "project", req.Name)
result.NextSteps = append(result.NextSteps,
"Initial deployment failed - run POST /projects/{id}/deploy after first build completes",
)
return
}
s.logger.Info("initial deployment created",
"project", 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 {
s.logger.Error("failed to update project with deployment info", "error", err, "project", req.Name)
}
}
// 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
}
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 {
s.logger.Warn("failed to load project domains", "error", err, "project", 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 {
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 all DNS records for project domains
s.deleteDNSRecords(ctx, status)
// 3. 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 {
s.logger.Warn("failed to delete project domains", "error", err)
}
}
// 4. Delete git repo (optional - might want to keep it)
// Skipping git repo deletion for safety
// 5. 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
}
func (s *ProjectInfraService) deleteDNSRecords(ctx context.Context, status *ProjectStatus) {
if s.dns == nil {
return
}
// 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 {
s.logger.Warn("failed to delete DNS record by ID", "error", 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 {
s.logger.Warn("failed to delete DNS record by name", "error", 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 {
s.logger.Warn("failed to delete DNS record", "error", 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)
}