Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Adds complete Slate documentation infrastructure to generated projects:
- docs/ directory with Gemfile, config.rb, and source templates
- Dockerfile for building docs site
- Dockerfile.nginx for serving static docs
- generate-docs.sh script for CI integration
- Claude command for AI-assisted docs generation
- OpenAPI → Slate markdown conversion via widdershins
Also includes:
- --export-openapi flag for service binaries
- DNS provisioning for docs.{domain} subdomain
- Updated project_infra for docs DNS records
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
911 lines
32 KiB
Go
911 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.
|
|
if req.Template == "skeleton" {
|
|
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()
|
|
}
|