Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following hexagonal architecture. These enable automated project provisioning: - Git repository creation/management via Gitea - DNS record management via Cloudflare - Container deployment to Kubernetes Includes domain models, ports, handlers, and Woodpecker CI webhook integration for automated deployments on push. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
384 lines
11 KiB
Go
384 lines
11 KiB
Go
// 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
|
|
}
|