This commit captures the current state before implementing the composable monorepo template system. Key changes included: Infrastructure: - Add CockroachDB provisioner adapter for database provisioning - Add Redis provisioner adapter for cache provisioning - Add build events system with PostgreSQL storage - Add WebSocket endpoint for real-time build progress Code agent improvements: - Fix Claude Code adapter to use default allowed tools instead of dangerously-skip-permissions - Add context-aware stream closing for cancellation support - Improve parser tests for edge cases Build system: - Add build event constants and metrics - Remove deprecated git_operations.go (replaced by pod_git_operations.go) - Add rollback logic for multi-step provisioning operations Documentation: - Add composable-monorepo feature documentation - Add DNS/Cloudflare service documentation - Update deployment and troubleshooting guides Cookbooks: - Add fullstack-app cookbook - Refactor landing-test with shared library Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
343 lines
10 KiB
Go
343 lines
10 KiB
Go
package worker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// PodGitOperations provides git operations that run inside a Kubernetes pod
|
|
// via kubectl exec. This ensures git commands execute in the same environment
|
|
// where the code agent runs.
|
|
type PodGitOperations struct {
|
|
namespace string
|
|
giteaToken string
|
|
gitUser string
|
|
gitEmail string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// PodGitOperationsConfig configures pod git operations.
|
|
type PodGitOperationsConfig struct {
|
|
// Namespace is the Kubernetes namespace for kubectl exec.
|
|
Namespace string
|
|
|
|
// GiteaToken is the token for HTTPS push authentication.
|
|
GiteaToken string
|
|
|
|
// GitUser is the git commit author name.
|
|
GitUser string
|
|
|
|
// GitEmail is the git commit author email.
|
|
GitEmail string
|
|
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewPodGitOperations creates a new pod git operations helper.
|
|
func NewPodGitOperations(cfg PodGitOperationsConfig) *PodGitOperations {
|
|
if cfg.GitUser == "" {
|
|
cfg.GitUser = "rdev-worker"
|
|
}
|
|
if cfg.GitEmail == "" {
|
|
cfg.GitEmail = "worker@threesix.ai"
|
|
}
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = slog.Default()
|
|
}
|
|
return &PodGitOperations{
|
|
namespace: cfg.Namespace,
|
|
giteaToken: cfg.GiteaToken,
|
|
gitUser: cfg.GitUser,
|
|
gitEmail: cfg.GitEmail,
|
|
logger: cfg.Logger.With("component", "pod-git-ops"),
|
|
}
|
|
}
|
|
|
|
// PostBuildResult contains the result of post-build git operations.
|
|
type PostBuildResult struct {
|
|
HasChanges bool
|
|
CommitSHA string
|
|
FilesChanged []string
|
|
Pushed bool
|
|
Error error
|
|
}
|
|
|
|
// CloneResult contains the result of a git clone operation.
|
|
type CloneResult struct {
|
|
Cloned bool // True if repo was cloned, false if already existed
|
|
Error error
|
|
}
|
|
|
|
// IsGitRepo checks if the given directory is a git repository.
|
|
func (g *PodGitOperations) IsGitRepo(ctx context.Context, podName, workDir string) bool {
|
|
// Check if .git directory exists
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"test", "-d", workDir + "/.git",
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// CloneRepo clones a git repository into the workspace if it doesn't already exist.
|
|
// If the workspace already contains a git repo, it pulls the latest changes instead.
|
|
// If the workspace exists but is not a git repo, it clears the directory first.
|
|
func (g *PodGitOperations) CloneRepo(ctx context.Context, podName, workDir, cloneURL string) *CloneResult {
|
|
result := &CloneResult{}
|
|
|
|
if cloneURL == "" {
|
|
result.Error = fmt.Errorf("git clone URL is required")
|
|
return result
|
|
}
|
|
|
|
// Check if already a git repo with the correct remote
|
|
if g.IsGitRepo(ctx, podName, workDir) {
|
|
// Verify the remote URL matches the expected clone URL
|
|
currentRemote, err := g.runGitInPodOutput(ctx, podName, workDir, "config", "--get", "remote.origin.url")
|
|
currentRemote = strings.TrimSpace(currentRemote)
|
|
expectedURL := cloneURL
|
|
|
|
// Normalize URLs for comparison (both should be HTTPS)
|
|
if err == nil && currentRemote == expectedURL {
|
|
g.logger.Info("workspace is already a git repo with correct remote, pulling latest",
|
|
"pod", podName,
|
|
"workDir", workDir,
|
|
)
|
|
// Pull latest changes
|
|
if err := g.runGitInPod(ctx, podName, workDir, "pull", "--ff-only"); err != nil {
|
|
// Pull failed, but repo exists - not fatal, might have local changes
|
|
g.logger.Warn("git pull failed, continuing with existing state",
|
|
"pod", podName,
|
|
"error", err,
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Remote doesn't match - this is a different project's repo
|
|
g.logger.Info("workspace has different git remote, will re-clone",
|
|
"pod", podName,
|
|
"workDir", workDir,
|
|
"currentRemote", currentRemote,
|
|
"expectedURL", expectedURL,
|
|
)
|
|
}
|
|
|
|
// Check if directory exists but is not a git repo - clear it first
|
|
if g.dirExists(ctx, podName, workDir) {
|
|
g.logger.Info("workspace exists but is not a git repo, clearing",
|
|
"pod", podName,
|
|
"workDir", workDir,
|
|
)
|
|
// Clear the directory contents (but keep the directory itself)
|
|
clearArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"sh", "-c", fmt.Sprintf("rm -rf %s/* %s/.[!.]*", workDir, workDir),
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", clearArgs...)
|
|
if err := cmd.Run(); err != nil {
|
|
g.logger.Warn("failed to clear workspace, attempting clone anyway",
|
|
"pod", podName,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Configure credential helper for clone (for private repos)
|
|
authCloneURL := cloneURL
|
|
if g.giteaToken != "" {
|
|
// Inject token into clone URL for authentication
|
|
// https://git.example.com/owner/repo.git -> https://token:TOKEN@git.example.com/owner/repo.git
|
|
authCloneURL = strings.Replace(cloneURL, "https://", "https://token:"+g.giteaToken+"@", 1)
|
|
}
|
|
|
|
g.logger.Info("cloning repository",
|
|
"pod", podName,
|
|
"workDir", workDir,
|
|
"url", cloneURL, // Log without token
|
|
)
|
|
|
|
// Clone the repository
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "clone", authCloneURL, workDir,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
result.Error = fmt.Errorf("git clone failed: %s: %s", err, errMsg)
|
|
return result
|
|
}
|
|
|
|
result.Cloned = true
|
|
g.logger.Info("repository cloned successfully",
|
|
"pod", podName,
|
|
"workDir", workDir,
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
// dirExists checks if a directory exists in the pod.
|
|
func (g *PodGitOperations) dirExists(ctx context.Context, podName, path string) bool {
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"test", "-d", path,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// CommitAndPush performs post-build git operations inside the pod:
|
|
// 1. Configures git user/email
|
|
// 2. Checks for changes (git status)
|
|
// 3. Stages all changes (git add -A)
|
|
// 4. Commits with the given message
|
|
// 5. Pushes if requested
|
|
//
|
|
// This is the programmatic alternative to relying on LLMs for git operations.
|
|
func (g *PodGitOperations) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *PostBuildResult {
|
|
result := &PostBuildResult{}
|
|
|
|
// Configure git user for commits
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "user.name", g.gitUser); err != nil {
|
|
result.Error = fmt.Errorf("git config user.name: %w", err)
|
|
return result
|
|
}
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "user.email", g.gitEmail); err != nil {
|
|
result.Error = fmt.Errorf("git config user.email: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Check for changes
|
|
status, err := g.runGitInPodOutput(ctx, podName, workDir, "status", "--porcelain")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git status: %w", err)
|
|
return result
|
|
}
|
|
if strings.TrimSpace(status) == "" {
|
|
g.logger.Info("no changes to commit", "pod", podName, "workDir", workDir)
|
|
return result
|
|
}
|
|
result.HasChanges = true
|
|
|
|
// Stage all changes
|
|
if err := g.runGitInPod(ctx, podName, workDir, "add", "-A"); err != nil {
|
|
result.Error = fmt.Errorf("git add: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get list of staged files
|
|
diffOutput, err := g.runGitInPodOutput(ctx, podName, workDir, "diff", "--cached", "--name-only")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git diff: %w", err)
|
|
return result
|
|
}
|
|
for _, f := range strings.Split(strings.TrimSpace(diffOutput), "\n") {
|
|
if f != "" {
|
|
result.FilesChanged = append(result.FilesChanged, f)
|
|
}
|
|
}
|
|
|
|
// Commit
|
|
if err := g.runGitInPod(ctx, podName, workDir, "commit", "-m", message); err != nil {
|
|
result.Error = fmt.Errorf("git commit: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get commit SHA
|
|
sha, err := g.runGitInPodOutput(ctx, podName, workDir, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git rev-parse: %w", err)
|
|
return result
|
|
}
|
|
result.CommitSHA = strings.TrimSpace(sha)
|
|
|
|
g.logger.Info("committed changes",
|
|
"pod", podName,
|
|
"sha", result.CommitSHA,
|
|
"files", len(result.FilesChanged),
|
|
)
|
|
|
|
// Push if requested
|
|
if push {
|
|
// Configure credential helper for push
|
|
if g.giteaToken != "" {
|
|
// Use git credential helper to inject token
|
|
// This avoids putting the token in the URL which would be visible in logs
|
|
credHelper := fmt.Sprintf("!f() { echo username=token; echo password=%s; }; f", g.giteaToken)
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "credential.helper", credHelper); err != nil {
|
|
g.logger.Warn("failed to configure credential helper", "error", err)
|
|
// Continue anyway - push might still work if pod has other auth configured
|
|
}
|
|
}
|
|
|
|
if err := g.runGitInPod(ctx, podName, workDir, "push", "origin", "HEAD"); err != nil {
|
|
result.Error = fmt.Errorf("git push: %w", err)
|
|
return result
|
|
}
|
|
result.Pushed = true
|
|
g.logger.Info("pushed changes", "pod", podName, "sha", result.CommitSHA)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// runGitInPod executes a git command inside the pod via kubectl exec.
|
|
func (g *PodGitOperations) runGitInPod(ctx context.Context, podName, workDir string, args ...string) error {
|
|
// Build: kubectl exec -n <namespace> <pod> -- git -C <workDir> <args...>
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "-C", workDir,
|
|
}
|
|
kubectlArgs = append(kubectlArgs, args...)
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
return fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runGitInPodOutput executes a git command and returns stdout.
|
|
func (g *PodGitOperations) runGitInPodOutput(ctx context.Context, podName, workDir string, args ...string) (string, error) {
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "-C", workDir,
|
|
}
|
|
kubectlArgs = append(kubectlArgs, args...)
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
return "", fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// redactToken removes the Gitea token from output.
|
|
func (g *PodGitOperations) redactToken(s string) string {
|
|
if g.giteaToken == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
|
|
}
|