// Package service provides business logic services. package service import ( "context" "database/sql" "encoding/base64" "fmt" "path/filepath" "regexp" "strconv" "strings" giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "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 // Infrastructure provisioners (optional - needed for postgres/redis components) dbProvisioner port.DatabaseProvisioner cacheProvisioner port.CacheProvisioner credentialStore port.CredentialStore } // ComponentServiceConfig configures the component service. type ComponentServiceConfig struct { DefaultGitOwner string // e.g., "threesix" RegistryURL string // e.g., "registry.threesix.ai" } // NewComponentService creates a new component service. func NewComponentService( db *sql.DB, templateProvider port.TemplateProvider, bulkClient *giteaadapter.BulkFileClient, deployer port.Deployer, cfg ComponentServiceConfig, ) *ComponentService { return &ComponentService{ db: db, templateProvider: templateProvider, bulkClient: bulkClient, deployer: deployer, defaultGitOwner: cfg.DefaultGitOwner, registryURL: cfg.RegistryURL, } } // Ensure ComponentService implements the interface. var _ port.ComponentService = (*ComponentService)(nil) // WithDatabaseProvisioner adds a database provisioner for postgres component support. func (s *ComponentService) WithDatabaseProvisioner(p port.DatabaseProvisioner) *ComponentService { s.dbProvisioner = p return s } // WithCacheProvisioner adds a cache provisioner for redis component support. func (s *ComponentService) WithCacheProvisioner(p port.CacheProvisioner) *ComponentService { s.cacheProvisioner = p return s } // WithCredentialStore adds a credential store for storing provisioned credentials. func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *ComponentService { s.credentialStore = cs return s } // AddComponent adds a new component to a project's monorepo. // For code components (service, worker, app-*, cli), this scaffolds template files. // For infrastructure components (postgres, redis), this provisions the resource. 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. Route infrastructure components to provisioning if componentType.IsInfraComponent() { return s.addInfraComponent(ctx, projectID, componentType, req.Name) } // --- Code component flow (service, worker, app-*, cli) --- // 4. 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 (use actual git host, not github.com) goModule = fmt.Sprintf("git.threesix.ai/%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) } log := logging.FromContext(ctx).WithService("component") log.Info("component added successfully", logging.FieldProjectID, 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 } // 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 { log := logging.FromContext(ctx).WithService("component") log.Warn("failed to get woodpecker step template", logging.FieldError, 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 }