rdev/internal/service/component.go

551 lines
18 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"log/slog"
"path/filepath"
"regexp"
"strconv"
"strings"
giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// procfilePatterns contains pre-compiled regex patterns for parsing Procfile entries
// by component type. Compiled once at package init for performance.
var procfilePatterns = make(map[domain.ComponentType]*regexp.Regexp)
func init() {
// Pre-compile regex patterns for each component type's Procfile entries
for _, ct := range domain.ValidComponentTypes {
destDir := ct.DestDir()
if destDir != "" {
// Pattern matches: "component-name: cd services/component-name && ..."
procfilePatterns[ct] = regexp.MustCompile(`^([a-z][a-z0-9-]*): cd (` + destDir + `/[a-z0-9-]+)`)
}
}
}
// ComponentService manages components within monorepo projects.
type ComponentService struct {
db *sql.DB
templateProvider port.TemplateProvider
bulkClient *giteaadapter.BulkFileClient
deployer port.Deployer
defaultGitOwner string
registryURL string
logger *slog.Logger
}
// ComponentServiceConfig configures the component service.
type ComponentServiceConfig struct {
DefaultGitOwner string // e.g., "threesix"
RegistryURL string // e.g., "registry.threesix.ai"
Logger *slog.Logger
}
// NewComponentService creates a new component service.
func NewComponentService(
db *sql.DB,
templateProvider port.TemplateProvider,
bulkClient *giteaadapter.BulkFileClient,
deployer port.Deployer,
cfg ComponentServiceConfig,
) *ComponentService {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &ComponentService{
db: db,
templateProvider: templateProvider,
bulkClient: bulkClient,
deployer: deployer,
defaultGitOwner: cfg.DefaultGitOwner,
registryURL: cfg.RegistryURL,
logger: logger,
}
}
// Ensure ComponentService implements the interface.
var _ port.ComponentService = (*ComponentService)(nil)
// AddComponent adds a new component to a project's monorepo.
func (s *ComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
// 1. Validate component type
if !domain.IsValidComponentType(req.Type) {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type)
}
componentType := domain.ComponentType(req.Type)
// 2. Validate component name
if err := domain.ValidateComponentName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %s", err, req.Name)
}
// 3. Get project info from database
var gitRepoOwner, gitRepoName, goModule string
var projectDomain string
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1), COALESCE(domain, '')
FROM projects WHERE id = $1
`, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName, &projectDomain)
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)
}
// Build Go module path
goModule = fmt.Sprintf("github.com/%s/%s", gitRepoOwner, gitRepoName)
// 4. Calculate component path
destDir := componentType.DestDir()
componentPath := filepath.Join(destDir, req.Name)
// 5. Check for duplicate component by checking for key files
checkFile := componentPath + "/go.mod"
if componentType == domain.ComponentTypeAppAstro || componentType == domain.ComponentTypeAppReact {
checkFile = componentPath + "/package.json"
}
existingContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile)
if err != nil {
return nil, fmt.Errorf("failed to check for existing component: %w", err)
}
if existingContent != nil {
return nil, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath)
}
// 6. Assign port if needed
port := req.Port
if port == 0 && componentType.NeedsPort() {
port, err = s.assignPort(ctx, projectID, componentType)
if err != nil {
return nil, fmt.Errorf("failed to assign port: %w", err)
}
}
// 7. Prepare template variables
vars := map[string]string{
"PROJECT_NAME": projectID,
"GO_MODULE": goModule,
"COMPONENT_NAME": req.Name,
"PORT": strconv.Itoa(port),
"DOMAIN": projectDomain,
}
// 8. Get component template files
componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars)
if err != nil {
return nil, fmt.Errorf("failed to get component template files: %w", err)
}
// 9. Read and update monorepo files
fileOps, err := s.prepareMonorepoUpdates(ctx, gitRepoOwner, gitRepoName, componentType, req.Name, componentPath, port, vars)
if err != nil {
return nil, fmt.Errorf("failed to prepare monorepo updates: %w", err)
}
// 10. Add component files to the operations
for _, cf := range componentFiles {
// Skip the .woodpecker.step.yml file - it's merged into main .woodpecker.yml
if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") {
continue
}
encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content))
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "create",
Path: cf.Path,
Content: encodedContent,
})
}
// 11. Commit all files in a single atomic commit
opts := giteaadapter.ChangeFilesOptions{
Files: fileOps,
Message: fmt.Sprintf("Add %s component: %s", req.Type, req.Name),
}
_, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts)
if err != nil {
return nil, fmt.Errorf("failed to commit component files: %w", err)
}
s.logger.Info("component added successfully",
"project", projectID,
"component_type", req.Type,
"component_name", req.Name,
"path", componentPath,
"port", port,
)
// 12. Build and return the component
component := &domain.Component{
Type: componentType,
Name: req.Name,
Path: componentPath,
Port: port,
Template: req.Type,
Dependencies: []string{}, // Could be parsed from component.yaml
}
// 13. Create initial K8s deployment for components that need one.
// This ensures kubectl set image will find the deployment when CI runs.
s.createInitialComponentDeployment(ctx, projectID, projectDomain, component)
return component, nil
}
// assignPort finds the next available port for a component type.
func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) {
// Get existing components to find the highest used port
components, err := s.ListComponents(ctx, projectID)
if err != nil {
return 0, err
}
startingPort := componentType.StartingPort()
if startingPort == 0 {
return 0, nil // Component type doesn't need a port
}
maxPort := startingPort - 1
for _, c := range components {
// Only consider components that share the same port range
if c.Type.StartingPort() == startingPort && c.Port > maxPort {
maxPort = c.Port
}
}
return maxPort + 1, nil
}
// createInitialComponentDeployment creates a K8s Deployment for a newly added component.
// This ensures the deployment exists before CI runs, so kubectl set image succeeds.
// Failures are logged but don't fail the component creation.
func (s *ComponentService) createInitialComponentDeployment(
ctx context.Context,
projectID, projectDomain string,
component *domain.Component,
) {
// Skip if no deployer or component doesn't need a deployment
if s.deployer == nil || !component.Type.NeedsPort() {
return
}
// Build the image path - uses "latest" as placeholder until CI builds a real image
image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name)
spec := domain.DeploySpec{
ProjectName: projectID,
ComponentPath: component.Path,
Image: image,
Domain: projectDomain,
Port: component.Port,
Replicas: 1,
}
if err := s.deployer.Deploy(ctx, spec); err != nil {
s.logger.Warn("failed to create initial component deployment",
"project", projectID,
"component", component.Name,
"error", err,
)
return
}
s.logger.Info("created initial component deployment",
"project", projectID,
"component", component.Name,
"image", image,
)
}
// prepareMonorepoUpdates reads existing monorepo files and prepares updates.
func (s *ComponentService) prepareMonorepoUpdates(
ctx context.Context,
owner, repo string,
componentType domain.ComponentType,
componentName, componentPath string,
port int,
vars map[string]string,
) ([]giteaadapter.ChangeFileOperation, error) {
var fileOps []giteaadapter.ChangeFileOperation
// 1. Update Procfile
procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "Procfile")
if err != nil {
return nil, fmt.Errorf("failed to get Procfile: %w", err)
}
if procfileContent != nil {
updated := s.updateProcfile(string(procfileContent), componentType, componentName, componentPath, port)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "Procfile",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: procfileSHA,
})
}
// 2. Update go.work for Go components
if componentType.IsGoComponent() {
goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "go.work")
if err != nil {
return nil, fmt.Errorf("failed to get go.work: %w", err)
}
if goWorkContent != nil {
updated := s.updateGoWork(string(goWorkContent), componentPath)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "go.work",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: goWorkSHA,
})
}
}
// 3. Update .woodpecker.yml
woodpeckerContent, woodpeckerSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, ".woodpecker.yml")
if err != nil {
return nil, fmt.Errorf("failed to get .woodpecker.yml: %w", err)
}
if woodpeckerContent != nil {
// Get the CI step template for this component type
stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, string(componentType), vars)
if err != nil {
s.logger.Warn("failed to get woodpecker step template", "error", err)
} else {
updated := s.updateWoodpeckerYml(string(woodpeckerContent), stepYaml)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: ".woodpecker.yml",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: woodpeckerSHA,
})
}
}
// 4. Update CLAUDE.md
claudeMdContent, claudeMdSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "CLAUDE.md")
if err != nil {
return nil, fmt.Errorf("failed to get CLAUDE.md: %w", err)
}
if claudeMdContent != nil {
updated := s.updateClaudeMd(string(claudeMdContent), componentType, componentName, componentPath)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "CLAUDE.md",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: claudeMdSHA,
})
}
return fileOps, nil
}
// ListComponents lists all components in a project's monorepo.
func (s *ComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) {
// Get project info from database
var gitRepoOwner, gitRepoName string
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1)
FROM projects WHERE id = $1
`, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName)
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)
}
// Read Procfile once (not inside the loop)
procfileContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile")
if err != nil {
s.logger.Warn("failed to read Procfile", "error", err)
return []domain.Component{}, nil
}
if procfileContent == nil {
return []domain.Component{}, nil
}
var components []domain.Component
procfileStr := string(procfileContent)
// Check each component type's directory
for _, ct := range domain.ValidComponentTypes {
destDir := ct.DestDir()
if destDir == "" {
continue
}
// Parse Procfile to extract component info for this type
comps := s.parseComponentsFromProcfile(procfileStr, ct)
components = append(components, comps...)
}
return components, nil
}
// parseComponentsFromProcfile extracts component information from a Procfile.
// Ports are assigned incrementally based on discovery order within each component type.
func (s *ComponentService) parseComponentsFromProcfile(procfile string, componentType domain.ComponentType) []domain.Component {
var components []domain.Component
// Use pre-compiled pattern from package-level map
pattern, ok := procfilePatterns[componentType]
if !ok {
return components
}
startingPort := componentType.StartingPort()
portOffset := 0
for _, line := range strings.Split(procfile, "\n") {
matches := pattern.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) >= 3 {
name := matches[1]
path := matches[2]
// Assign ports incrementally based on discovery order
port := 0
if componentType.NeedsPort() {
port = startingPort + portOffset
portOffset++
}
components = append(components, domain.Component{
Type: componentType,
Name: name,
Path: path,
Port: port,
Template: string(componentType),
Dependencies: []string{},
})
}
}
return components
}
// RemoveComponent removes a component from a project's monorepo.
func (s *ComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error {
// Get project info from database
var gitRepoOwner, gitRepoName string
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1)
FROM projects WHERE id = $1
`, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName)
if err == sql.ErrNoRows {
return fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return fmt.Errorf("failed to get project: %w", err)
}
// Verify component exists by checking for a file in the path
checkFile := componentPath + "/go.mod"
content, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile)
if err != nil {
return fmt.Errorf("failed to check component: %w", err)
}
if content == nil {
// Try package.json for frontend apps
checkFile = componentPath + "/package.json"
content, _, err = s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile)
if err != nil {
return fmt.Errorf("failed to check component: %w", err)
}
if content == nil {
return fmt.Errorf("%w: %s", domain.ErrComponentNotFound, componentPath)
}
}
// Extract component name from path
componentName := filepath.Base(componentPath)
// Determine component type from path
var componentType domain.ComponentType
switch {
case strings.HasPrefix(componentPath, "services/"):
componentType = domain.ComponentTypeService
case strings.HasPrefix(componentPath, "workers/"):
componentType = domain.ComponentTypeWorker
case strings.HasPrefix(componentPath, "apps/"):
// Could be astro or react - check package.json later
componentType = domain.ComponentTypeAppAstro
case strings.HasPrefix(componentPath, "cli/"):
componentType = domain.ComponentTypeCLI
default:
return fmt.Errorf("unknown component path structure: %s", componentPath)
}
// For now, we'll update the monorepo files to remove references
// Actual file deletion would require listing all files in the directory
// which the Gitea API doesn't support easily
var fileOps []giteaadapter.ChangeFileOperation
// 1. Update Procfile - remove the component entry
procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile")
if err != nil {
return fmt.Errorf("failed to get Procfile: %w", err)
}
if procfileContent != nil {
updated := s.removeProcfileEntry(string(procfileContent), componentName)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "Procfile",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: procfileSHA,
})
}
// 2. Update go.work if Go component
if componentType.IsGoComponent() {
goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "go.work")
if err != nil {
return fmt.Errorf("failed to get go.work: %w", err)
}
if goWorkContent != nil {
updated := s.removeGoWorkEntry(string(goWorkContent), componentPath)
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "go.work",
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
SHA: goWorkSHA,
})
}
}
// Note: Removing from .woodpecker.yml and CLAUDE.md is more complex
// because we'd need to parse YAML and markdown tables properly.
// For now, we'll leave those as manual cleanup tasks.
if len(fileOps) > 0 {
opts := giteaadapter.ChangeFilesOptions{
Files: fileOps,
Message: fmt.Sprintf("Remove component references: %s", componentName),
}
_, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts)
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
}
s.logger.Info("component removed",
"project", projectID,
"path", componentPath,
"note", "Component files remain in repo - delete manually if needed",
)
return nil
}