rdev/internal/service/project_infra.go
jordan c86516c53a feat: Add multi-domain support with auto-generated slugs for landing page cookbook
Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:55:59 -07:00

186 lines
5.1 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"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
domainRepo port.ProjectDomainRepository
slugGenerator port.SlugGenerator
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,
domainRepo port.ProjectDomainRepository,
slugGenerator port.SlugGenerator,
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,
domainRepo: domainRepo,
slugGenerator: slugGenerator,
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")
CustomSubdomain string // Optional: custom subdomain (e.g., "my-app" for my-app.threesix.ai)
}
// CreateProjectResult contains the result of project creation.
type CreateProjectResult struct {
ProjectID string
Name string
Description string
Slug string // Auto-generated unique identifier
// Git info
GitRepoOwner string
GitRepoName string
CloneSSH string
CloneHTTP string
HTMLURL string
// Domain info (primary domain for backward compatibility)
Domain string
URL string
// All domains associated with the project
Domains []*domain.ProjectDomain
// Next steps
NextSteps []string
}
// ProjectStatus represents the current status of a project.
type ProjectStatus struct {
ProjectID string
Name string
Description string
Slug string
// Git
GitRepoOwner string
GitRepoName string
CloneSSH string
CloneHTTP string
HTMLURL string
// Domain (primary for backward compatibility)
Domain string
CustomDomain string
URL string
// All domains associated with the project
Domains []*domain.ProjectDomain
// Deployment
DeploymentImage string
DeploymentStatus string
DeploymentReplicas int
ReadyReplicas int
// Site health
SiteLive bool // True if the site responds with HTTP 200
SiteError string // Error message if site check failed
}
// AddDomainRequest contains parameters for adding a domain to a project.
type AddDomainRequest struct {
ProjectID string
Domain string // Full domain (e.g., "my-app.threesix.ai" or "custom.example.com")
Type domain.DomainType // DomainTypePrimaryCustom or DomainTypeAlias
RecordType string // "A" or "CNAME" (default: "A")
Proxied bool // Cloudflare proxy enabled
}
// checkSiteHealth performs an HTTP GET request to check if a site is live.
// Returns (true, "") if the site returns HTTP 200, otherwise (false, errorMessage).
func checkSiteHealth(ctx context.Context, url string) (bool, string) {
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, fmt.Sprintf("failed to create request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Sprintf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// Accept 2xx and 3xx status codes as "live"
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
return true, ""
}
return false, fmt.Sprintf("HTTP %d", resp.StatusCode)
}