Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
AddComponent (single-component path) already calls ensureProjectJWTSecret,
but AddComponentBatch has its own parallel implementation that bypassed it.
Components added via the /batch endpoint never had JWT_SECRET provisioned,
causing CrashLoopBackOff on startup ("JWT_SECRET must be set").
Add the call before the createInitialComponentDeployment loop so the secret
is stored in the credential store before K8s Secrets are created.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
314 lines
10 KiB
Go
314 lines
10 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. 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
|
|
}
|