All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
338 lines
11 KiB
Go
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
|
|
}
|