rdev/internal/worker/sdlc_executor.go
jordan b6e778d5ab
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(git): harden git flow for concurrent SDLC stress test failures
5 fixes from stress test analysis:

1. CRITICAL: Add pull-before-push to claudebox GitOperations.CommitAndPush,
   matching the fix already in PodGitOperations (prevents push rejections
   when concurrent builds advance the remote).

2. HIGH: Extract ResetToMain into PodGitOperations as a shared public method.
   Wire into BuildExecutor after CloneRepo and update SDLCTaskExecutor to
   use the shared method. Prevents builds from running on wrong branch when
   worker pods are reused across tasks.

3. HIGH: Make branch create push failure fatal with retry+rollback in
   cmd/sdlc/cmd_branch.go. Prevents orphaned .sdlc/ state that causes
   merge failures after completing all 10 SDLC phases.

4. MEDIUM: Shell-escape token in credential helpers (both PodGitOperations
   and claudebox GitOperations) to prevent shell injection via tokens
   containing special characters.

5. MEDIUM: Add GitResetToMain to claudebox sidecar (git.go implementation,
   server.go endpoint, client.go HTTP method) and wire into
   HTTPSDLCTaskExecutor for the HTTP sidecar path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:57:27 -07:00

295 lines
8.4 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(),
}
}
// 1b. Reset workspace to main to ensure a known-good starting state.
// Worker pods are reused across tasks and may be left on a feature branch
// from a previous command (e.g., `sdlc branch create` switches branches).
if err := e.podGitOps.ResetToMain(ctx, podName, workDir); err != nil {
log.Warn("failed to reset workspace to main, continuing",
"task_id", task.ID,
logging.FieldError, err,
)
}
// 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 + "'"
}