// 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) }