rdev/internal/service/project_infra.go
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

441 lines
13 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"regexp"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// projectNameRegex validates project names for DNS and K8s compatibility.
// Must be lowercase, start with a letter, contain only letters, numbers, and dashes.
var projectNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// reservedProjectNames are names that cannot be used for projects.
var reservedProjectNames = map[string]bool{
"www": true,
"api": true,
"git": true,
"ci": true,
"registry": true,
"admin": true,
"root": true,
"rdev": true,
"pantheon": true,
}
// ValidateProjectName validates that a project name is safe for use as
// a DNS subdomain, K8s resource name, and git repository name.
func ValidateProjectName(name string) error {
if name == "" {
return errors.New("project name cannot be empty")
}
if len(name) > 63 {
return errors.New("project name too long (max 63 characters)")
}
if !projectNameRegex.MatchString(name) {
return errors.New("project name must be lowercase, start with a letter, and contain only letters, numbers, and dashes")
}
if reservedProjectNames[name] {
return fmt.Errorf("'%s' is a reserved name", name)
}
return nil
}
// 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
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,
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,
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")
}
// CreateProjectResult contains the result of project creation.
type CreateProjectResult struct {
ProjectID string
Name string
Description string
// Git info
GitRepoOwner string
GitRepoName string
CloneSSH string
CloneHTTP string
HTMLURL string
// Domain info
Domain string
URL string
// Next steps
NextSteps []string
}
// CreateProject creates a new project with git repo and DNS.
// This is the main orchestration method for /project create.
func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) {
// Validate project name first
if err := ValidateProjectName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidProjectName, err)
}
s.logger.Info("creating project", "name", req.Name)
// 1. Create project in database
projectID := req.Name // Use name as ID for simplicity
now := time.Now()
_, err := s.db.ExecContext(ctx, `
INSERT INTO projects (id, name, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
updated_at = EXCLUDED.updated_at
`, projectID, req.Name, req.Description, now, now)
if err != nil {
return nil, fmt.Errorf("failed to create project in database: %w", err)
}
result := &CreateProjectResult{
ProjectID: projectID,
Name: req.Name,
Description: req.Description,
Domain: req.Name + "." + s.defaultDomain,
}
result.URL = "https://" + result.Domain
// 2. Create git repository
if s.gitRepo != nil {
repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private)
if err != nil {
s.logger.Error("failed to create git repo", "error", err)
result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create")
} else {
result.GitRepoOwner = repo.Owner
result.GitRepoName = repo.Name
result.CloneSSH = repo.CloneSSH
result.CloneHTTP = repo.CloneHTTP
result.HTMLURL = repo.HTMLURL
// Update database with git info
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET
git_repo_owner = $1,
git_repo_name = $2,
git_clone_ssh = $3,
git_clone_http = $4,
git_html_url = $5,
updated_at = $6
WHERE id = $7
`, repo.Owner, repo.Name, repo.CloneSSH, repo.CloneHTTP, repo.HTMLURL, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with git info", "error", err, "project", projectID)
// Continue - the git repo was created, we just failed to record it
}
}
} else {
result.NextSteps = append(result.NextSteps, "Git repository service not configured")
}
// 3. Create DNS record
if s.dns != nil {
_, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: req.Name,
Content: s.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
s.logger.Warn("failed to create DNS record", "error", err)
result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+req.Name+"."+s.defaultDomain+" → "+s.clusterIP)
} else {
// Update database with domain
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3
`, result.Domain, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with domain", "error", err, "project", projectID)
// Continue - the DNS was created, we just failed to record it
}
}
} else {
result.NextSteps = append(result.NextSteps, "DNS service not configured")
}
// 4. Activate CI (Woodpecker)
if s.ciProvider != nil && result.GitRepoOwner != "" {
ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName)
if err != nil {
s.logger.Warn("failed to activate CI", "error", err)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName),
)
} else {
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
}
} else if s.ciProvider == nil {
result.NextSteps = append(result.NextSteps, "CI provider not configured")
}
// 5. Seed repository with template
if s.templateProvider != nil && result.GitRepoOwner != "" {
templateName := req.Template
if templateName == "" {
templateName = "default"
}
// Prepare template variables
vars := map[string]string{
"PROJECT_NAME": req.Name,
"DOMAIN": result.Domain,
"GIT_URL": result.CloneHTTP,
}
err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars)
if err != nil {
s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Add template files manually (template: %s)", templateName),
)
} else {
s.logger.Info("repo seeded with template", "template", templateName)
}
} else if s.templateProvider == nil {
result.NextSteps = append(result.NextSteps, "Template provider not configured")
}
s.logger.Info("project created successfully",
"project", req.Name,
"git_repo", result.CloneSSH,
"domain", result.Domain,
)
return result, nil
}
// GetProjectStatus returns the current status of a project.
type ProjectStatus struct {
ProjectID string
Name string
Description string
// Git
GitRepoOwner string
GitRepoName string
CloneSSH string
CloneHTTP string
HTMLURL string
// Domain
Domain string
CustomDomain string
URL string
// Deployment
DeploymentImage string
DeploymentStatus string
DeploymentReplicas int
ReadyReplicas int
}
// GetStatus returns the current status of a project.
func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (*ProjectStatus, error) {
var status ProjectStatus
err := s.db.QueryRowContext(ctx, `
SELECT
id, name, COALESCE(description, ''),
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''),
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''),
COALESCE(domain, ''), COALESCE(custom_domain, ''),
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'),
COALESCE(deployment_replicas, 1)
FROM projects WHERE id = $1
`, projectID).Scan(
&status.ProjectID, &status.Name, &status.Description,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err)
}
if status.Domain != "" {
status.URL = "https://" + status.Domain
}
// Get live deployment status if deployer is available
if s.deployer != nil {
deployStatus, err := s.deployer.GetStatus(ctx, projectID)
if err == nil && deployStatus != nil {
status.DeploymentStatus = string(deployStatus.Status)
status.ReadyReplicas = deployStatus.ReadyReplicas
if deployStatus.URL != "" {
status.URL = deployStatus.URL
}
}
}
return &status, nil
}
// ListProjects returns all projects.
func (s *ProjectInfraService) ListProjects(ctx context.Context) ([]*ProjectStatus, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
id, name, COALESCE(description, ''),
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''),
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''),
COALESCE(domain, ''), COALESCE(custom_domain, ''),
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'),
COALESCE(deployment_replicas, 1)
FROM projects
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("failed to list projects: %w", err)
}
defer func() { _ = rows.Close() }()
var projects []*ProjectStatus
for rows.Next() {
var status ProjectStatus
err := rows.Scan(
&status.ProjectID, &status.Name, &status.Description,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err != nil {
continue
}
if status.Domain != "" {
status.URL = "https://" + status.Domain
}
projects = append(projects, &status)
}
return projects, nil
}
// DeleteProject removes a project and its associated resources.
func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID string) error {
s.logger.Info("deleting project", "project", projectID)
// Get project info first
status, err := s.GetStatus(ctx, projectID)
if err != nil {
return err
}
// 1. Undeploy if deployed
if s.deployer != nil && status.DeploymentStatus != "none" {
if err := s.deployer.Undeploy(ctx, projectID); err != nil {
s.logger.Warn("failed to undeploy", "error", err)
}
}
// 2. Delete DNS record
if s.dns != nil && status.Domain != "" {
subdomain := status.Name
if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil {
s.logger.Warn("failed to delete DNS record", "error", err)
}
}
// 3. Delete git repo (optional - might want to keep it)
// Skipping git repo deletion for safety
// 4. Delete from database
_, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID)
if err != nil {
return fmt.Errorf("failed to delete project from database: %w", err)
}
s.logger.Info("project deleted", "project", projectID)
return nil
}
// ListTemplates returns available project templates.
func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.ListTemplates(ctx)
}
// GetTemplate returns info about a specific template.
func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.GetTemplate(ctx, name)
}