rdev/internal/service/project_infra.go
jordan 0f25bd8dbe
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: hook in notify service for per-project email delivery
- Add NotifyProvisioner (port + adapter) using real notify admin API
- Create notify account + send key + host grant per project
- Inject NOTIFY_API_KEY/HOST/FROM into component deployments
- Store NOTIFY_URL, NOTIFY_ADMIN_KEY, RESEND_API_KEY in credential store
- Add setup-notify.sh for one-time host/provider/domain setup
- Add NOTIFY_ADMIN_KEY constant to domain/credential.go
- Wire provisioner in main.go with connection test guard
- Add .claude/guides/services/notify.md and CLAUDE.md entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:30:32 -07:00

241 lines
7.3 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"database/sql"
"fmt"
"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
storageProvisioner port.StorageProvisioner
notifyProvisioner port.NotifyProvisioner
registryProvider port.RegistryProvider
citadelClient port.CitadelClient
// 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"
}
// 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 {
registryURL := cfg.RegistryURL
if registryURL == "" {
registryURL = "registry.threesix.ai" // TODO: Remove hardcoded fallback — set REGISTRY_URL in K8s manifest instead
}
return &ProjectInfraService{
db: db,
gitRepo: gitRepo,
dns: dns,
deployer: deployer,
ciProvider: ciProvider,
templateProvider: templateProvider,
domainRepo: domainRepo,
slugGenerator: slugGenerator,
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
}
// WithStorageProvisioner sets the storage provisioner for project storage (GCS).
func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner) *ProjectInfraService {
s.storageProvisioner = sp
return s
}
// WithNotifyProvisioner sets the notify provisioner for project email delivery.
func (s *ProjectInfraService) WithNotifyProvisioner(np port.NotifyProvisioner) *ProjectInfraService {
s.notifyProvisioner = np
return s
}
// WithRegistryProvider sets the container registry provider for image cleanup.
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
s.registryProvider = rp
return s
}
// WithCitadelClient sets the Citadel client for auto-provisioning log environments.
func (s *ProjectInfraService) WithCitadelClient(cc port.CitadelClient) *ProjectInfraService {
s.citadelClient = cc
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
// API documentation URL (docs.{slug}.{domain})
DocsURL string
// All domains associated with the project
Domains []*domain.ProjectDomain
// Citadel log environment (set during provisioning, used for k8s label routing)
CitadelTenantID string
// 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)
}