rdev/internal/service/component_batch.go
jordan 2612de8446
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: inject SERVICE_NAME/SERVICE_PORT for app components in batch path
AddComponentBatch was missing the SERVICE_NAME injection that AddComponent
has. When an app-react component (e.g. creator-ui) was rendered via the
batch endpoint alongside a service component, {{SERVICE_NAME}} in App.tsx.tmpl
was never substituted — rendering the literal string into the repo.

Fix: scan the batch's own code requests for a service component first
(since the service isn't in the DB yet during batch processing), then
fall back to findFirstServiceComponent from DB.

This is the same AddComponent vs AddComponentBatch parity gap that caused
the JWT_SECRET issue (RC-2). The auth API URL in every app-react project
was broken when deployed via the batch endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:52:03 -07:00

338 lines
11 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,
}
// For frontend apps, inject the primary service name/port for API proxy.
// Check the batch itself first (service not yet in DB), then fall back to DB.
if componentType.IsAppComponent() {
var svcName string
var svcPort int
for _, r := range codeReqs {
if domain.ComponentType(r.Type) == domain.ComponentTypeService {
svcName = r.Name
svcPort = r.Port
break
}
}
if svcName == "" {
if svc := s.findFirstServiceComponent(ctx, projectID); svc != nil {
svcName = svc.Name
svcPort = svc.Port
}
}
if svcName != "" {
vars["SERVICE_NAME"] = svcName
vars["SERVICE_PORT"] = strconv.Itoa(svcPort)
}
}
// 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. Ensure a JWT_SECRET exists for this project (required by skeleton service startup).
// Generated once per project on the first code component; reused for all subsequent components.
s.ensureProjectJWTSecret(ctx, projectID)
// 10. Create initial K8s deployments for components that need one
for _, comp := range codeComponents {
s.createInitialComponentDeployment(ctx, projectID, projectDomain, comp)
}
// 11. Combine infrastructure and code component results
results = append(results, codeComponents...)
return results, nil
}