rdev/internal/service/component_batch.go
jordan 853ec4cf81 fix: go.work race condition with batch components and idempotent provisioning
Three coordinated fixes for CI pipeline race conditions:

1. Woodpecker step dependencies: Added depends_on: [deps] to all 6 component
   templates (service, worker, cli, app-astro, app-react, app-nextjs) so build
   steps wait for go work sync to complete.

2. Idempotent resource provisioning: Modified provisionResources() to check
   for existing database/cache before creating, preventing "already exists"
   errors on component re-adds.

3. Batch component endpoint: POST /projects/{id}/components/batch enables
   atomic multi-component additions in a single git commit. Validates all
   components upfront, provisions infra sequentially, commits code components
   atomically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:31:40 -07:00

310 lines
9.9 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"path/filepath"
"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"
)
// AddComponentBatch adds multiple components in a single atomic operation.
// All components are validated upfront, then committed in a single git commit.
// Infrastructure components (postgres, redis) are provisioned sequentially before code components.
func (s *ComponentService) AddComponentBatch(ctx context.Context, projectID string, reqs []port.AddComponentRequest) ([]*domain.Component, error) {
if len(reqs) == 0 {
return nil, fmt.Errorf("at least one component is required")
}
log := logging.FromContext(ctx).WithService("component")
// 1. Validate all components upfront
var infraReqs []port.AddComponentRequest
var codeReqs []port.AddComponentRequest
for _, req := range reqs {
// Validate component type
if !domain.IsValidComponentType(req.Type) {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type)
}
componentType := domain.ComponentType(req.Type)
// Validate component name
if err := domain.ValidateComponentName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %s", err, req.Name)
}
// Separate infrastructure from code components
if componentType.IsInfraComponent() {
infraReqs = append(infraReqs, req)
} else {
codeReqs = append(codeReqs, req)
}
}
// Check for duplicate names in the batch
seen := make(map[string]bool)
for _, req := range reqs {
key := req.Type + ":" + req.Name
if seen[key] {
return nil, fmt.Errorf("%w: duplicate component %s/%s in batch", domain.ErrDuplicateComponent, req.Type, req.Name)
}
seen[key] = true
}
results := make([]*domain.Component, 0, len(reqs))
// 2. Provision infrastructure components first (these don't need git commits)
for _, req := range infraReqs {
componentType := domain.ComponentType(req.Type)
component, err := s.addInfraComponent(ctx, projectID, componentType, req.Name)
if err != nil {
return results, fmt.Errorf("failed to provision %s component %s: %w", req.Type, req.Name, err)
}
results = append(results, component)
}
// 3. If no code components, we're done
if len(codeReqs) == 0 {
return results, nil
}
// 4. Get project info from database (needed for code components)
var gitRepoOwner, gitRepoName 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 results, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return results, fmt.Errorf("failed to get project: %w", err)
}
goModule := fmt.Sprintf("git.threesix.ai/%s/%s", gitRepoOwner, gitRepoName)
// 5. Prepare all file operations for code components
var allFileOps []giteaadapter.ChangeFileOperation
var codeComponents []*domain.Component
// Track files we've already fetched/modified to avoid duplicate fetches
type fileState struct {
content []byte
sha string
}
fileCache := make(map[string]*fileState)
// Helper to get file content (cached)
getFile := func(path string) ([]byte, string, error) {
if cached, ok := fileCache[path]; ok {
return cached.content, cached.sha, nil
}
content, sha, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, path)
if err != nil {
return nil, "", err
}
fileCache[path] = &fileState{content: content, sha: sha}
return content, sha, nil
}
// 6. Process each code component
for _, req := range codeReqs {
componentType := domain.ComponentType(req.Type)
destDir := componentType.DestDir()
componentPath := filepath.Join(destDir, req.Name)
// 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 results, fmt.Errorf("failed to check for existing component %s: %w", req.Name, err)
}
if existingContent != nil {
return results, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath)
}
// Assign port if needed
port := req.Port
if port == 0 && componentType.NeedsPort() {
port, err = s.assignPort(ctx, projectID, componentType)
if err != nil {
return results, fmt.Errorf("failed to assign port for %s: %w", req.Name, err)
}
}
// Prepare template variables
vars := map[string]string{
"PROJECT_NAME": projectID,
"GO_MODULE": goModule,
"COMPONENT_NAME": req.Name,
"PORT": strconv.Itoa(port),
"DOMAIN": projectDomain,
}
// Get component template files
componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars)
if err != nil {
return results, fmt.Errorf("failed to get component template files for %s: %w", req.Name, err)
}
// Add component files to operations
for _, cf := range componentFiles {
if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") {
continue
}
encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content))
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "create",
Path: cf.Path,
Content: encodedContent,
})
}
// Track component for later
codeComponents = append(codeComponents, &domain.Component{
Type: componentType,
Name: req.Name,
Path: componentPath,
Port: port,
Template: req.Type,
Dependencies: []string{},
})
}
// 7. Prepare monorepo file updates (Procfile, go.work, .woodpecker.yml, CLAUDE.md)
// These need to be accumulated across all components
// Update Procfile
procfileContent, procfileSHA, err := getFile("Procfile")
if err != nil {
return results, fmt.Errorf("failed to get Procfile: %w", err)
}
if procfileContent != nil {
updatedProcfile := string(procfileContent)
for i, comp := range codeComponents {
updatedProcfile = s.updateProcfile(updatedProcfile, comp.Type, comp.Name, comp.Path, comp.Port)
// Update cache for next iteration
fileCache["Procfile"] = &fileState{content: []byte(updatedProcfile), sha: procfileSHA}
_ = i // silence unused
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "Procfile",
Content: base64.StdEncoding.EncodeToString([]byte(updatedProcfile)),
SHA: procfileSHA,
})
}
// Update go.work (only for Go components)
goWorkContent, goWorkSHA, err := getFile("go.work")
if err != nil {
return results, fmt.Errorf("failed to get go.work: %w", err)
}
if goWorkContent != nil {
updatedGoWork := string(goWorkContent)
for _, comp := range codeComponents {
if comp.Type.IsGoComponent() {
updatedGoWork = s.updateGoWork(updatedGoWork, comp.Path)
}
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "go.work",
Content: base64.StdEncoding.EncodeToString([]byte(updatedGoWork)),
SHA: goWorkSHA,
})
}
// Update .woodpecker.yml
woodpeckerContent, woodpeckerSHA, err := getFile(".woodpecker.yml")
if err != nil {
return results, fmt.Errorf("failed to get .woodpecker.yml: %w", err)
}
if woodpeckerContent != nil {
updatedWoodpecker := string(woodpeckerContent)
for i, req := range codeReqs {
comp := codeComponents[i]
vars := map[string]string{
"PROJECT_NAME": projectID,
"GO_MODULE": goModule,
"COMPONENT_NAME": comp.Name,
"PORT": strconv.Itoa(comp.Port),
"DOMAIN": projectDomain,
}
stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, req.Type, vars)
if err != nil {
log.Warn("failed to get woodpecker step template", logging.FieldError, err, "component", comp.Name)
continue
}
updatedWoodpecker = s.updateWoodpeckerYml(updatedWoodpecker, stepYaml)
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: ".woodpecker.yml",
Content: base64.StdEncoding.EncodeToString([]byte(updatedWoodpecker)),
SHA: woodpeckerSHA,
})
}
// Update CLAUDE.md
claudeMdContent, claudeMdSHA, err := getFile("CLAUDE.md")
if err != nil {
return results, fmt.Errorf("failed to get CLAUDE.md: %w", err)
}
if claudeMdContent != nil {
updatedClaudeMd := string(claudeMdContent)
for _, comp := range codeComponents {
updatedClaudeMd = s.updateClaudeMd(updatedClaudeMd, comp.Type, comp.Name, comp.Path)
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "CLAUDE.md",
Content: base64.StdEncoding.EncodeToString([]byte(updatedClaudeMd)),
SHA: claudeMdSHA,
})
}
// 8. Commit all files in a single atomic commit
componentNames := make([]string, len(codeReqs))
for i, req := range codeReqs {
componentNames[i] = req.Type + "/" + req.Name
}
opts := giteaadapter.ChangeFilesOptions{
Files: allFileOps,
Message: fmt.Sprintf("Add components: %s", strings.Join(componentNames, ", ")),
}
_, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts)
if err != nil {
return results, fmt.Errorf("failed to commit component files: %w", err)
}
log.Info("batch components added successfully",
logging.FieldProjectID, projectID,
"count", len(codeComponents),
"components", componentNames,
)
// 9. Create initial K8s deployments for components that need one
for _, comp := range codeComponents {
s.createInitialComponentDeployment(ctx, projectID, projectDomain, comp)
}
// 10. Combine infrastructure and code component results
results = append(results, codeComponents...)
return results, nil
}