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>
285 lines
8.0 KiB
Go
285 lines
8.0 KiB
Go
package worker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
)
|
|
|
|
// SDLCTaskExecutor handles WorkTaskTypeSDLC tasks by executing sdlc CLI commands
|
|
// inside worker pods via kubectl exec. This enables SDLC operations on skeleton
|
|
// projects that don't have dedicated pods.
|
|
type SDLCTaskExecutor struct {
|
|
podGitOps *PodGitOperations
|
|
namespace string
|
|
}
|
|
|
|
// SDLCTaskExecutorConfig holds configuration for the SDLC task executor.
|
|
type SDLCTaskExecutorConfig struct {
|
|
// Namespace is the Kubernetes namespace for kubectl exec.
|
|
Namespace string
|
|
|
|
// PodGitOps provides git clone/commit/push operations.
|
|
PodGitOps *PodGitOperations
|
|
}
|
|
|
|
// NewSDLCTaskExecutor creates a new SDLC task executor.
|
|
func NewSDLCTaskExecutor(cfg SDLCTaskExecutorConfig) *SDLCTaskExecutor {
|
|
return &SDLCTaskExecutor{
|
|
podGitOps: cfg.PodGitOps,
|
|
namespace: cfg.Namespace,
|
|
}
|
|
}
|
|
|
|
// Execute runs an SDLC task by cloning the repo, executing the sdlc CLI command,
|
|
// and optionally committing/pushing changes.
|
|
func (e *SDLCTaskExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
|
|
start := time.Now()
|
|
log := logging.FromContext(ctx).WithWorker("sdlc-task-executor")
|
|
|
|
spec, err := e.parseSpec(task.Spec)
|
|
if err != nil {
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("invalid SDLC spec: %v", err),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
// Use the first available claudebox worker pod
|
|
podName := "claudebox-0"
|
|
|
|
// Working directory in the pod
|
|
workDir := "/workspace"
|
|
|
|
log.Info("executing SDLC task",
|
|
"task_id", task.ID,
|
|
logging.FieldProjectID, task.ProjectID,
|
|
"command", spec.Command,
|
|
logging.FieldPodName, podName,
|
|
)
|
|
|
|
// 1. Clone repo to worker pod
|
|
if e.podGitOps == nil {
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: "pod git operations not configured",
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
cloneResult := e.podGitOps.CloneRepo(ctx, podName, workDir, spec.GitCloneURL)
|
|
if cloneResult.Error != nil {
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("git clone failed: %v", cloneResult.Error),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
// 2. Ensure .sdlc/ is initialized (auto-init for skeleton projects)
|
|
if err := e.ensureSDLCInit(ctx, podName, workDir); err != nil {
|
|
log.Warn("sdlc init check failed, continuing anyway",
|
|
"task_id", task.ID,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
|
|
// 3. Run SDLC CLI command
|
|
output, err := e.runSDLCCommand(ctx, podName, workDir, spec.Command, spec.Args)
|
|
if err != nil {
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("sdlc command failed: %v", err),
|
|
Output: output,
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
result := &domain.BuildResult{
|
|
Success: true,
|
|
Output: output,
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
|
|
// 4. Commit and push if enabled
|
|
if spec.AutoCommit {
|
|
commitMsg := fmt.Sprintf("sdlc: %s", spec.Command)
|
|
gitResult := e.podGitOps.CommitAndPush(ctx, podName, workDir, commitMsg, spec.AutoPush)
|
|
if gitResult.Error != nil {
|
|
result.Success = false
|
|
result.Error = fmt.Sprintf("git operations failed: %v", gitResult.Error)
|
|
return result
|
|
}
|
|
if gitResult.HasChanges {
|
|
result.CommitSHA = gitResult.CommitSHA
|
|
result.FilesChanged = gitResult.FilesChanged
|
|
log.Info("SDLC changes committed",
|
|
"task_id", task.ID,
|
|
"commit", gitResult.CommitSHA,
|
|
"files", len(gitResult.FilesChanged),
|
|
"pushed", gitResult.Pushed,
|
|
)
|
|
}
|
|
}
|
|
|
|
log.Info("SDLC task completed",
|
|
"task_id", task.ID,
|
|
"command", spec.Command,
|
|
logging.FieldDuration, result.DurationMs,
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
// ensureSDLCInit checks if .sdlc/ exists and runs `sdlc init` if it doesn't.
|
|
// This enables SDLC operations on skeleton projects that don't have .sdlc/ pre-initialized.
|
|
func (e *SDLCTaskExecutor) ensureSDLCInit(ctx context.Context, podName, workDir string) error {
|
|
log := logging.FromContext(ctx).WithWorker("sdlc-task-executor")
|
|
|
|
// Check if .sdlc/ directory exists
|
|
checkArgs := []string{
|
|
"exec", "-n", e.namespace, podName, "--",
|
|
"sh", "-c",
|
|
fmt.Sprintf("test -d %s/.sdlc && echo exists || echo missing", workDir),
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", checkArgs...)
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("check .sdlc failed: %w", err)
|
|
}
|
|
|
|
if strings.TrimSpace(stdout.String()) == "exists" {
|
|
return nil // Already initialized
|
|
}
|
|
|
|
// Run sdlc init
|
|
log.Info("initializing .sdlc directory", logging.FieldPodName, podName, "workDir", workDir)
|
|
|
|
initArgs := []string{
|
|
"exec", "-n", e.namespace, podName, "--",
|
|
"sh", "-c",
|
|
fmt.Sprintf("cd %s && sdlc init --json", workDir),
|
|
}
|
|
|
|
initCmd := exec.CommandContext(ctx, "kubectl", initArgs...)
|
|
var initStdout, initStderr bytes.Buffer
|
|
initCmd.Stdout = &initStdout
|
|
initCmd.Stderr = &initStderr
|
|
|
|
if err := initCmd.Run(); err != nil {
|
|
return fmt.Errorf("sdlc init failed: %w: %s", err, initStderr.String())
|
|
}
|
|
|
|
log.Info("sdlc initialized", logging.FieldPodName, podName, "output", initStdout.String())
|
|
return nil
|
|
}
|
|
|
|
// runSDLCCommand executes the sdlc CLI command in the worker pod.
|
|
func (e *SDLCTaskExecutor) runSDLCCommand(ctx context.Context, podName, workDir, command string, args []string) (string, error) {
|
|
// Build the full command: sdlc {command} {args...} --json
|
|
// Each argument is quoted to handle values with spaces (e.g., --title "My Feature")
|
|
sdlcArgs := []string{shellQuote(command)}
|
|
for _, arg := range args {
|
|
sdlcArgs = append(sdlcArgs, shellQuote(arg))
|
|
}
|
|
sdlcArgs = append(sdlcArgs, "--json")
|
|
|
|
// Build kubectl exec command
|
|
kubectlArgs := []string{
|
|
"exec", "-n", e.namespace, podName, "--",
|
|
"sh", "-c",
|
|
fmt.Sprintf("cd %s && sdlc %s", workDir, strings.Join(sdlcArgs, " ")),
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return stdout.String(), fmt.Errorf("%s: %s", err, stderr.String())
|
|
}
|
|
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// parsedSDLCSpec holds typed fields extracted from the task spec map.
|
|
type parsedSDLCSpec struct {
|
|
Command string
|
|
Args []string
|
|
GitCloneURL string
|
|
AutoCommit bool
|
|
AutoPush bool
|
|
}
|
|
|
|
// parseSpec extracts typed SDLCTaskSpec fields from the generic map[string]any.
|
|
func (e *SDLCTaskExecutor) parseSpec(spec map[string]any) (*parsedSDLCSpec, error) {
|
|
command, _ := spec["command"].(string)
|
|
if command == "" {
|
|
return nil, fmt.Errorf("command is required")
|
|
}
|
|
|
|
gitCloneURL, _ := spec["git_clone_url"].(string)
|
|
if gitCloneURL == "" {
|
|
return nil, fmt.Errorf("git_clone_url is required")
|
|
}
|
|
|
|
autoCommit, _ := spec["auto_commit"].(bool)
|
|
autoPush, _ := spec["auto_push"].(bool)
|
|
|
|
// Parse args (can be []string or []any from JSON)
|
|
var args []string
|
|
if argsRaw, ok := spec["args"]; ok {
|
|
switch v := argsRaw.(type) {
|
|
case []string:
|
|
args = v
|
|
case []any:
|
|
for _, a := range v {
|
|
if s, ok := a.(string); ok {
|
|
args = append(args, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &parsedSDLCSpec{
|
|
Command: command,
|
|
Args: args,
|
|
GitCloneURL: gitCloneURL,
|
|
AutoCommit: autoCommit,
|
|
AutoPush: autoPush,
|
|
}, nil
|
|
}
|
|
|
|
// SDLCResult represents the parsed JSON output from an SDLC command.
|
|
// Used by WorkerSDLCExecutor to parse results.
|
|
type SDLCResult struct {
|
|
Success bool `json:"success"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// shellQuote escapes a string for safe use in a shell command.
|
|
// It wraps the string in single quotes and escapes any single quotes within.
|
|
func shellQuote(s string) string {
|
|
// If the string contains no special characters, return as-is
|
|
if !strings.ContainsAny(s, " \t\n'\"\\$`!*?[]{}|&;<>()") {
|
|
return s
|
|
}
|
|
// Escape single quotes by ending the quoted section, adding an escaped quote, and restarting
|
|
// 'foo'bar' becomes 'foo'"'"'bar'
|
|
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
|
|
return "'" + escaped + "'"
|
|
}
|