rdev/internal/service/project_infra.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

215 lines
6.2 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, deployment,
// and database/cache provisioning.
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
credentialStore port.CredentialStore
dbProvisioner port.DatabaseProvisioner
cacheProvisioner port.CacheProvisioner
logger *slog.Logger
// Config
defaultGitOwner string
defaultDomain string
clusterIP string
registryURL string // e.g., "registry.threesix.ai"
}
// 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"
RegistryURL string // e.g., "registry.threesix.ai"
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()
}
registryURL := cfg.RegistryURL
if registryURL == "" {
registryURL = "registry.threesix.ai" // Default for backward compatibility
}
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,
registryURL: registryURL,
}
}
// WithCredentialStore sets the credential store for storing provisioned credentials.
func (s *ProjectInfraService) WithCredentialStore(cs port.CredentialStore) *ProjectInfraService {
s.credentialStore = cs
return s
}
// WithDatabaseProvisioner sets the database provisioner for project databases.
func (s *ProjectInfraService) WithDatabaseProvisioner(dp port.DatabaseProvisioner) *ProjectInfraService {
s.dbProvisioner = dp
return s
}
// WithCacheProvisioner sets the cache provisioner for project cache access.
func (s *ProjectInfraService) WithCacheProvisioner(cp port.CacheProvisioner) *ProjectInfraService {
s.cacheProvisioner = cp
return s
}
// 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: "skeleton" for composable monorepos)
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)
}