- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
411 lines
12 KiB
Go
411 lines
12 KiB
Go
// Package service provides business logic services.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// ValidateProjectName validates that a project name is safe for use as
|
|
// a DNS subdomain, K8s resource name, and git repository name.
|
|
// Delegates to domain.ValidateProjectName for centralized validation.
|
|
func ValidateProjectName(name string) error {
|
|
return domain.ValidateProjectName(name)
|
|
}
|
|
|
|
// ProjectInfraService orchestrates project infrastructure operations.
|
|
// It coordinates git repo creation, DNS, CI activation, template seeding, and deployment.
|
|
type ProjectInfraService struct {
|
|
db *sql.DB
|
|
gitRepo port.GitRepository
|
|
dns port.DNSProvider
|
|
deployer port.Deployer
|
|
ciProvider port.CIProvider
|
|
templateProvider port.TemplateProvider
|
|
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,
|
|
ciProvider port.CIProvider,
|
|
templateProvider port.TemplateProvider,
|
|
cfg ProjectInfraConfig,
|
|
) *ProjectInfraService {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &ProjectInfraService{
|
|
db: db,
|
|
gitRepo: gitRepo,
|
|
dns: dns,
|
|
deployer: deployer,
|
|
ciProvider: ciProvider,
|
|
templateProvider: templateProvider,
|
|
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
|
|
Template string // Template to seed the repo with (default: "default")
|
|
}
|
|
|
|
// 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("%w: %w", domain.ErrInvalidProjectName, 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. Activate CI (Woodpecker)
|
|
if s.ciProvider != nil && result.GitRepoOwner != "" {
|
|
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),
|
|
)
|
|
} else {
|
|
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
|
|
}
|
|
} else if s.ciProvider == nil {
|
|
result.NextSteps = append(result.NextSteps, "CI provider not configured")
|
|
}
|
|
|
|
// 5. Seed repository with template
|
|
if s.templateProvider != nil && result.GitRepoOwner != "" {
|
|
templateName := req.Template
|
|
if templateName == "" {
|
|
templateName = "default"
|
|
}
|
|
|
|
// Prepare template variables
|
|
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),
|
|
)
|
|
} else {
|
|
s.logger.Info("repo seeded with template", "template", templateName)
|
|
}
|
|
} else if s.templateProvider == nil {
|
|
result.NextSteps = append(result.NextSteps, "Template provider not configured")
|
|
}
|
|
|
|
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("%w: %s", domain.ErrProjectNotFound, 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
|
|
}
|
|
|
|
// 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)
|
|
}
|