Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
295 lines
8.4 KiB
Go
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 + "'"
|
|
}
|