rdev/internal/service/project_infra.go
jordan a9ad3d8304
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
chore: accumulated platform hardening and CI fixes
CI / Woodpecker:
- Add explicit depends_on to all .woodpecker.yml steps (rdev + templates)
- Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name)
- Add replicasets get/list to deployer RBAC for rollout status
- Skeleton template: add failure:ignore on docs steps, Traefik TLS
  annotations on ingress, depends_on on verify step

Component templates:
- Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME)
- Replace kubectl scale with kubectl patch for replicas
- Add post-deploy image verification and rollout status checks
- Applied consistently across all 5 component templates

Adapters:
- gitea: Add HTTP client timeout (30s), context cancellation checks,
  handle 404 on GetRepo/DeleteRepo
- zot: Add retry with exponential backoff (doWithRetry), limit response
  body reads to 10MB
- cockroach: Use net.JoinHostPort for IPv6-safe DSN construction
- woodpecker: Fix error wrapping (%v -> %w)
- redis: Fix error wrapping (%v -> %w)
- deployer: Add context cancellation checks

Services:
- apikey_service: Fix error wrapping (%v -> %w)
- component_deploy: Fix error wrapping (%v -> %w)
- project_infra: Fix error wrapping (%v -> %w)
- webhook/dispatcher: Fix error wrapping (%v -> %w)

Other:
- CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3,
  Traefik v3, Zot registry
- circuitbreaker: Add test for error wrapping
- docs: Update deployment, troubleshooting, and runbook docs
- health: Fix error wrapping (%v -> %w)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:16:56 -07:00

224 lines
6.7 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
registryProvider port.RegistryProvider
// 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
}
// WithRegistryProvider sets the container registry provider for image cleanup.
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
s.registryProvider = rp
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
// 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)
}