1128 lines
32 KiB
Go
1128 lines
32 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
sprites "github.com/superfly/sprites-go"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
"github.com/kandev/kandev/internal/agent/remoteauth"
|
|
agentctl "github.com/kandev/kandev/internal/agentctl/client"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/scriptengine"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
)
|
|
|
|
type RemoteAuthAgentLister interface {
|
|
ListEnabled() []agents.Agent
|
|
}
|
|
|
|
// spriteFileUploader implements FileUploader for a Sprites instance.
|
|
type spriteFileUploader struct {
|
|
sprite *sprites.Sprite
|
|
runtime *SpritesExecutor
|
|
}
|
|
|
|
func (u *spriteFileUploader) WriteFile(ctx context.Context, path string, data []byte, mode os.FileMode) error {
|
|
if u.runtime == nil {
|
|
return u.sprite.Filesystem().WriteFileContext(ctx, path, data, mode)
|
|
}
|
|
return u.runtime.writeFileWithRetry(ctx, u.sprite, path, data, mode)
|
|
}
|
|
|
|
func (u *spriteFileUploader) RunCommand(ctx context.Context, name string, args ...string) error {
|
|
_, err := u.sprite.CommandContext(ctx, name, args...).Output()
|
|
return err
|
|
}
|
|
|
|
func (u *spriteFileUploader) RunCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return u.sprite.CommandContext(ctx, name, args...).Output()
|
|
}
|
|
|
|
type commandOutputRunner interface {
|
|
RunCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
const (
|
|
spritesAgentctlPort = 8765
|
|
spritesWorkspacePath = "/workspace"
|
|
spritesNamePrefix = "kandev-"
|
|
spriteStepTimeout = 120 * time.Second
|
|
spriteHealthTimeout = 15 * time.Second
|
|
spriteDestroyTimeout = 30 * time.Second
|
|
spriteHealthRetryWait = 500 * time.Millisecond
|
|
spritesTotalSteps = 7 // create, upload, credentials, prepare script, health, create instance, network policy
|
|
spriteOutputMaxLines = 20
|
|
spriteUploadMaxRetries = 3
|
|
)
|
|
|
|
var uploadHTTPStatusRE = regexp.MustCompile(`(?i)\b(?:http|status)\s*:?\s*(\d{3})\b`)
|
|
|
|
// SpritesProxySession tracks an active port-forwarding session to a sprite.
|
|
type SpritesProxySession struct {
|
|
spriteName string
|
|
localPort int
|
|
proxySession *sprites.ProxySession
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// SpritesExecutor implements ExecutorBackend for Sprites.dev remote sandboxes.
|
|
type SpritesExecutor struct {
|
|
secretStore secrets.SecretStore
|
|
agentList RemoteAuthAgentLister
|
|
agentctlResolver *AgentctlResolver
|
|
logger *logger.Logger
|
|
agentctlPort int
|
|
mu sync.RWMutex
|
|
proxies map[string]*SpritesProxySession
|
|
tokens map[string]string // cached API tokens per instance
|
|
}
|
|
|
|
// NewSpritesExecutor creates a new Sprites runtime.
|
|
func NewSpritesExecutor(
|
|
secretStore secrets.SecretStore,
|
|
agentList RemoteAuthAgentLister,
|
|
resolver *AgentctlResolver,
|
|
agentctlPort int,
|
|
log *logger.Logger,
|
|
) *SpritesExecutor {
|
|
return &SpritesExecutor{
|
|
secretStore: secretStore,
|
|
agentList: agentList,
|
|
agentctlResolver: resolver,
|
|
logger: log.WithFields(zap.String("runtime", "sprites")),
|
|
agentctlPort: agentctlPort,
|
|
proxies: make(map[string]*SpritesProxySession),
|
|
tokens: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) Name() executor.Name {
|
|
return executor.NameSprites
|
|
}
|
|
|
|
func (r *SpritesExecutor) HealthCheck(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) ResumeRemoteInstance(_ context.Context, req *ExecutorCreateRequest) error {
|
|
if req == nil {
|
|
return fmt.Errorf("request is nil")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
|
|
token := req.Env["SPRITES_API_TOKEN"]
|
|
if token == "" {
|
|
return nil, fmt.Errorf("SPRITES_API_TOKEN not set in execution environment (configure it in the executor profile)")
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.tokens[req.InstanceID] = token
|
|
r.mu.Unlock()
|
|
|
|
spriteName := spritesNamePrefix + req.InstanceID[:12]
|
|
client := sprites.New(token, sprites.WithDisableControl())
|
|
reconnectRequired := req.PreviousExecutionID != ""
|
|
if reconnectRequired {
|
|
suffix := req.PreviousExecutionID
|
|
if len(suffix) > 12 {
|
|
suffix = suffix[:12]
|
|
}
|
|
spriteName = spritesNamePrefix + suffix
|
|
}
|
|
|
|
r.logger.Info("creating sprite instance",
|
|
zap.String("instance_id", req.InstanceID),
|
|
zap.String("sprite_name", spriteName),
|
|
zap.Bool("reconnect_required", reconnectRequired))
|
|
|
|
report := newStepReporter(req.OnProgress)
|
|
var spriteInfo *sprites.Sprite
|
|
destroyOnFailure := !reconnectRequired
|
|
|
|
// Step 0: Create sprite
|
|
stepName := "Creating cloud sandbox"
|
|
if reconnectRequired {
|
|
stepName = "Reconnecting cloud sandbox"
|
|
}
|
|
step := beginStep(stepName)
|
|
report(step, 0)
|
|
var sprite *sprites.Sprite
|
|
var err error
|
|
if reconnectRequired {
|
|
sprite, err = r.reconnectSprite(ctx, client, spriteName)
|
|
if err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 0)
|
|
return nil, err
|
|
}
|
|
} else {
|
|
sprite, err = r.createSprite(ctx, client, spriteName)
|
|
if err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 0)
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
}
|
|
spriteInfo = sprite
|
|
completeStepSuccess(&step)
|
|
report(step, 0)
|
|
|
|
// Step 1: Upload agentctl binary
|
|
step = beginStep("Uploading agent controller")
|
|
report(step, 1)
|
|
if reconnectRequired {
|
|
completeStepSkipped(&step)
|
|
} else {
|
|
if err := r.uploadAgentctl(ctx, sprite); err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 1)
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
completeStepSuccess(&step)
|
|
}
|
|
report(step, 1)
|
|
|
|
// Step 2: Upload remote credentials (SSH keys, gh CLI, agent auth)
|
|
step = beginStep("Uploading credentials")
|
|
report(step, 2)
|
|
if reconnectRequired {
|
|
completeStepSkipped(&step)
|
|
} else {
|
|
if err := r.uploadCredentials(ctx, sprite, req, func(output string) {
|
|
step.Output = output
|
|
report(step, 2)
|
|
}); err != nil {
|
|
r.logger.Warn("failed to upload credentials (non-fatal)", zap.Error(err))
|
|
completeStepSkipped(&step)
|
|
} else {
|
|
completeStepSuccess(&step)
|
|
}
|
|
}
|
|
report(step, 2)
|
|
|
|
// Step 3: Run prepare script (with output streaming)
|
|
step = beginStep("Running prepare script")
|
|
report(step, 3)
|
|
if reconnectRequired {
|
|
completeStepSkipped(&step)
|
|
report(step, 3)
|
|
} else {
|
|
err = r.runPrepareScript(ctx, sprite, req, func(output string) {
|
|
step.Output = output
|
|
report(step, 3)
|
|
})
|
|
if err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 3)
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
completeStepSuccess(&step)
|
|
report(step, 3)
|
|
}
|
|
|
|
// Step 4: Wait for agentctl health
|
|
step = beginStep("Waiting for agent controller")
|
|
report(step, 4)
|
|
if err := r.waitForHealth(ctx, sprite); err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 4)
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
completeStepSuccess(&step)
|
|
report(step, 4)
|
|
|
|
// Step 5: Create agent instance (control server → per-instance server)
|
|
step = beginStep("Creating agent instance")
|
|
report(step, 5)
|
|
instancePort, err := r.createAgentInstance(ctx, sprite, req)
|
|
if err != nil {
|
|
completeStepError(&step, err.Error())
|
|
report(step, 5)
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
completeStepSuccess(&step)
|
|
report(step, 5)
|
|
|
|
// Step 6: Apply network policy
|
|
step = beginStep("Applying network policy")
|
|
report(step, 6)
|
|
if err := r.applyNetworkPolicy(ctx, client, spriteName, req); err != nil {
|
|
r.logger.Warn("failed to apply network policy from profile", zap.Error(err))
|
|
completeStepSkipped(&step)
|
|
} else {
|
|
completeStepSuccess(&step)
|
|
}
|
|
report(step, 6)
|
|
|
|
// Port forwarding to the per-instance server (not the control server)
|
|
localPort, err := r.setupPortForwarding(ctx, sprite, spriteName, req.InstanceID, instancePort)
|
|
if err != nil {
|
|
r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure)
|
|
return nil, err
|
|
}
|
|
|
|
agentctlClient := agentctl.NewClient("127.0.0.1", localPort, r.logger,
|
|
agentctl.WithExecutionID(req.InstanceID),
|
|
agentctl.WithSessionID(req.SessionID))
|
|
|
|
r.logger.Info("sprite instance ready",
|
|
zap.String("instance_id", req.InstanceID),
|
|
zap.String("sprite_name", spriteName),
|
|
zap.Int("local_port", localPort),
|
|
zap.Int("instance_port", instancePort))
|
|
|
|
return &ExecutorInstance{
|
|
InstanceID: req.InstanceID,
|
|
TaskID: req.TaskID,
|
|
SessionID: req.SessionID,
|
|
RuntimeName: string(r.Name()),
|
|
Client: agentctlClient,
|
|
WorkspacePath: spritesWorkspacePath,
|
|
Metadata: map[string]interface{}{
|
|
"sprite_name": spriteName,
|
|
"sprite_state": strings.TrimSpace(spriteInfo.Status),
|
|
"sprite_created_at": spriteInfo.CreatedAt,
|
|
"local_port": localPort,
|
|
MetadataKeyIsRemote: true,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) reconnectSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) {
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
sprite, err := client.GetSprite(stepCtx, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reconnect sprite %q: %w", name, err)
|
|
}
|
|
return sprite, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error {
|
|
spriteName := getMetadataString(instance.Metadata, "sprite_name")
|
|
if spriteName == "" {
|
|
return nil
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if proxy, ok := r.proxies[instance.InstanceID]; ok {
|
|
r.closeProxySession(proxy)
|
|
delete(r.proxies, instance.InstanceID)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
r.mu.RLock()
|
|
token := r.tokens[instance.InstanceID]
|
|
r.mu.RUnlock()
|
|
if token == "" {
|
|
r.logger.Warn("no cached API token for sprite instance, cannot destroy",
|
|
zap.String("instance_id", instance.InstanceID))
|
|
return nil
|
|
}
|
|
client := sprites.New(token)
|
|
sprite := client.Sprite(spriteName)
|
|
r.runTerminalCleanupScript(ctx, sprite, instance)
|
|
if err := sprite.Destroy(); err != nil {
|
|
r.logger.Warn("failed to destroy sprite",
|
|
zap.String("sprite_name", spriteName),
|
|
zap.Error(err))
|
|
return fmt.Errorf("failed to destroy sprite: %w", err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
delete(r.tokens, instance.InstanceID)
|
|
r.mu.Unlock()
|
|
|
|
r.logger.Info("sprite destroyed", zap.String("sprite_name", spriteName))
|
|
return nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) runTerminalCleanupScript(ctx context.Context, sprite *sprites.Sprite, instance *ExecutorInstance) {
|
|
if sprite == nil || instance == nil {
|
|
return
|
|
}
|
|
if !shouldRunExecutorCleanup(instance.StopReason) {
|
|
return
|
|
}
|
|
script := strings.TrimSpace(getMetadataString(instance.Metadata, MetadataKeyCleanupScript))
|
|
if script == "" {
|
|
return
|
|
}
|
|
|
|
resolver := scriptengine.NewResolver().
|
|
WithProvider(scriptengine.WorkspaceProvider(spritesWorkspacePath)).
|
|
WithProvider(scriptengine.AgentctlProvider(r.agentctlPort, spritesWorkspacePath)).
|
|
WithProvider(scriptengine.GitIdentityProvider(instance.Metadata)).
|
|
WithProvider(scriptengine.RepositoryProvider(
|
|
instance.Metadata,
|
|
nil,
|
|
getGitRemoteURL,
|
|
r.injectTokenIntoURL,
|
|
))
|
|
resolved := resolver.Resolve(script)
|
|
if strings.TrimSpace(resolved) == "" {
|
|
return
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "sh", "-c", resolved).CombinedOutput()
|
|
if err != nil {
|
|
r.logger.Warn("cleanup script failed in sprite",
|
|
zap.String("instance_id", instance.InstanceID),
|
|
zap.String("reason", instance.StopReason),
|
|
zap.String("output", strings.TrimSpace(lastLines(string(out), spriteOutputMaxLines))),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
r.logger.Debug("cleanup script completed in sprite",
|
|
zap.String("instance_id", instance.InstanceID),
|
|
zap.String("reason", instance.StopReason))
|
|
}
|
|
|
|
func shouldRunExecutorCleanup(reason string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(reason)) {
|
|
case "task archived", "task deleted", "session archived", "session deleted":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) {
|
|
if instance == nil {
|
|
return nil, fmt.Errorf("instance is nil")
|
|
}
|
|
spriteName := strings.TrimSpace(getMetadataString(instance.Metadata, "sprite_name"))
|
|
if spriteName == "" {
|
|
return nil, fmt.Errorf("sprite name not available in metadata")
|
|
}
|
|
|
|
r.mu.RLock()
|
|
token := r.tokens[instance.InstanceID]
|
|
r.mu.RUnlock()
|
|
if token == "" {
|
|
return nil, fmt.Errorf("sprites api token is not cached for instance")
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
client := sprites.New(token, sprites.WithDisableControl())
|
|
sprite, err := client.GetSprite(stepCtx, spriteName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &RemoteStatus{
|
|
RuntimeName: string(r.Name()),
|
|
RemoteName: spriteName,
|
|
State: normalizeSpriteState(sprite.Status),
|
|
CreatedAt: nonZeroTimePtr(sprite.CreatedAt),
|
|
LastCheckedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
func normalizeSpriteState(raw string) string {
|
|
state := strings.ToLower(strings.TrimSpace(raw))
|
|
if state == "" {
|
|
return "unknown"
|
|
}
|
|
return state
|
|
}
|
|
|
|
func nonZeroTimePtr(t time.Time) *time.Time {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
func (r *SpritesExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) GetInteractiveRunner() *process.InteractiveRunner {
|
|
return nil
|
|
}
|
|
|
|
// createAgentInstance creates a per-instance server on the agentctl control server
|
|
// running inside the sprite. Returns the port of the per-instance server.
|
|
func (r *SpritesExecutor) createAgentInstance(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
) (int, error) {
|
|
instanceReq := agentctl.CreateInstanceRequest{
|
|
ID: req.InstanceID,
|
|
WorkspacePath: spritesWorkspacePath,
|
|
SessionID: req.SessionID,
|
|
}
|
|
reqJSON, err := json.Marshal(instanceReq)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to marshal instance request: %w", err)
|
|
}
|
|
|
|
createCmd := fmt.Sprintf(
|
|
"curl -sf -X POST http://localhost:%d/api/v1/instances -H 'Content-Type: application/json' -d '%s'",
|
|
r.agentctlPort, string(reqJSON))
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "sh", "-c", createCmd).Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create agent instance: %w", err)
|
|
}
|
|
|
|
var resp agentctl.CreateInstanceResponse
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
return 0, fmt.Errorf("failed to parse instance response: %w (output: %s)", err, string(out))
|
|
}
|
|
|
|
r.logger.Debug("agent instance created inside sprite",
|
|
zap.String("instance_id", resp.ID),
|
|
zap.Int("port", resp.Port))
|
|
|
|
return resp.Port, nil
|
|
}
|
|
|
|
// uploadCredentials reads the remote_credentials metadata and uploads the selected
|
|
// credential files to the sprite. Also handles gh_cli_token auto-detect,
|
|
// secret-based auth via remote_auth_secrets, and agent auth setup scripts.
|
|
func (r *SpritesExecutor) uploadCredentials(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
onOutput func(string),
|
|
) error {
|
|
catalog := r.buildRemoteAuthCatalog()
|
|
|
|
// Handle secret-based auth (e.g., GITHUB_TOKEN from a stored secret)
|
|
r.resolveAuthSecrets(ctx, req, catalog)
|
|
|
|
// Run auth setup scripts for env-type methods (e.g., Claude Code credential files)
|
|
r.runAuthSetupScripts(ctx, sprite, req, catalog, onOutput)
|
|
|
|
credsJSON, _ := req.Metadata["remote_credentials"].(string)
|
|
if credsJSON == "" {
|
|
return nil
|
|
}
|
|
|
|
var selectedMethodIDs []string
|
|
if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil {
|
|
return fmt.Errorf("failed to parse remote_credentials: %w", err)
|
|
}
|
|
|
|
// Handle gh_cli_token: detect locally and inject as env var
|
|
selectedMethodIDs = r.resolveGHToken(selectedMethodIDs, req)
|
|
|
|
if len(selectedMethodIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
fileMethods := make([]remoteauth.Method, 0, len(selectedMethodIDs))
|
|
for _, methodID := range selectedMethodIDs {
|
|
method, ok := catalog.FindMethod(methodID)
|
|
if !ok {
|
|
r.logger.Warn("unknown remote auth method, skipping", zap.String("method_id", methodID))
|
|
continue
|
|
}
|
|
if method.Type != "files" {
|
|
continue
|
|
}
|
|
fileMethods = append(fileMethods, method)
|
|
}
|
|
if len(fileMethods) == 0 {
|
|
return nil
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
uploader := &spriteFileUploader{sprite: sprite, runtime: r}
|
|
targetHomeDir, err := r.resolveRemoteAuthHomeDir(stepCtx, req, uploader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger)
|
|
}
|
|
|
|
// runAuthSetupScripts executes setup scripts for env-type auth methods whose env var
|
|
// is present in req.Env. This handles both secret-store-resolved and directly-injected env vars.
|
|
// The optional onOutput callback streams output to the caller (e.g., progress UI).
|
|
func (r *SpritesExecutor) runAuthSetupScripts(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
catalog remoteauth.Catalog,
|
|
onOutput func(string),
|
|
) {
|
|
for _, spec := range catalog.Specs {
|
|
for _, method := range spec.Methods {
|
|
if method.Type != "env" || method.SetupScript == "" || method.EnvVar == "" {
|
|
continue
|
|
}
|
|
if req.Env[method.EnvVar] == "" {
|
|
continue
|
|
}
|
|
|
|
if onOutput != nil {
|
|
onOutput(fmt.Sprintf("Setting up %s credentials...", spec.DisplayName))
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
cmd := sprite.CommandContext(stepCtx, "sh", "-c", method.SetupScript)
|
|
cmd.Env = r.buildSpriteEnv(req.Env)
|
|
out, err := cmd.CombinedOutput()
|
|
cancel()
|
|
|
|
if err != nil {
|
|
r.logger.Warn("auth setup script failed",
|
|
zap.String("method_id", method.MethodID),
|
|
zap.String("output", strings.TrimSpace(string(out))),
|
|
zap.Error(err))
|
|
if onOutput != nil {
|
|
onOutput(fmt.Sprintf("Warning: %s credential setup failed", spec.DisplayName))
|
|
}
|
|
} else {
|
|
r.logger.Debug("auth setup script completed",
|
|
zap.String("method_id", method.MethodID))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveGHToken handles the gh_cli_token credential: detects the token locally
|
|
// and injects it as GITHUB_TOKEN in the request env. Returns filtered credential IDs.
|
|
func (r *SpritesExecutor) resolveGHToken(credentialIDs []string, req *ExecutorCreateRequest) []string {
|
|
if !containsID(credentialIDs, "gh_cli_token") {
|
|
return credentialIDs
|
|
}
|
|
token, err := DetectGHToken()
|
|
if err != nil {
|
|
r.logger.Warn("failed to detect gh token", zap.Error(err))
|
|
} else {
|
|
if req.Env == nil {
|
|
req.Env = make(map[string]string)
|
|
}
|
|
req.Env["GITHUB_TOKEN"] = token
|
|
r.logger.Debug("set GITHUB_TOKEN from local gh auth token")
|
|
}
|
|
return removeID(credentialIDs, "gh_cli_token")
|
|
}
|
|
|
|
// resolveAuthSecrets reads remote_auth_secrets from metadata and resolves secret values
|
|
// into environment variables (e.g., gh_cli secret → GITHUB_TOKEN).
|
|
func (r *SpritesExecutor) resolveAuthSecrets(
|
|
ctx context.Context,
|
|
req *ExecutorCreateRequest,
|
|
catalog remoteauth.Catalog,
|
|
) {
|
|
authSecretsJSON, _ := req.Metadata["remote_auth_secrets"].(string)
|
|
if authSecretsJSON == "" {
|
|
return
|
|
}
|
|
var authSecrets map[string]string
|
|
if err := json.Unmarshal([]byte(authSecretsJSON), &authSecrets); err != nil {
|
|
r.logger.Warn("failed to parse remote_auth_secrets", zap.Error(err))
|
|
return
|
|
}
|
|
for methodID, secretID := range authSecrets {
|
|
if secretID == "" {
|
|
continue
|
|
}
|
|
method, ok := catalog.FindMethod(methodID)
|
|
if !ok || method.Type != "env" || method.EnvVar == "" {
|
|
continue
|
|
}
|
|
value, err := r.secretStore.Reveal(ctx, secretID)
|
|
if err != nil {
|
|
r.logger.Warn("failed to resolve auth secret",
|
|
zap.String("method_id", methodID),
|
|
zap.String("secret_id", secretID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
if req.Env == nil {
|
|
req.Env = make(map[string]string)
|
|
}
|
|
req.Env[method.EnvVar] = value
|
|
r.logger.Debug("set env from auth secret", zap.String("key", method.EnvVar), zap.String("method_id", methodID))
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) buildRemoteAuthCatalog() remoteauth.Catalog {
|
|
if r.agentList == nil {
|
|
return remoteauth.BuildCatalog(nil)
|
|
}
|
|
return remoteauth.BuildCatalog(r.agentList.ListEnabled())
|
|
}
|
|
|
|
func (r *SpritesExecutor) resolveRemoteAuthHomeDir(
|
|
ctx context.Context,
|
|
req *ExecutorCreateRequest,
|
|
cmdRunner commandOutputRunner,
|
|
) (string, error) {
|
|
if req != nil && req.Metadata != nil {
|
|
if override, ok := req.Metadata[MetadataKeyRemoteAuthHome].(string); ok {
|
|
trimmed := strings.TrimSpace(override)
|
|
if trimmed != "" {
|
|
r.logger.Debug("using remote auth home override", zap.String("home_dir", trimmed))
|
|
return trimmed, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if cmdRunner == nil {
|
|
return "", fmt.Errorf("failed to resolve remote user home for credential upload: command runner unavailable")
|
|
}
|
|
|
|
out, err := cmdRunner.RunCommandOutput(ctx, "sh", "-lc", "printf %s \"$HOME\"")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve remote user home for credential upload: %w", err)
|
|
}
|
|
home := strings.TrimSpace(string(out))
|
|
if home == "" {
|
|
return "", fmt.Errorf("failed to resolve remote user home for credential upload: empty HOME")
|
|
}
|
|
r.logger.Debug("resolved remote auth home", zap.String("home_dir", home))
|
|
return home, nil
|
|
}
|
|
|
|
func containsID(ids []string, target string) bool {
|
|
for _, id := range ids {
|
|
if id == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func removeID(ids []string, target string) []string {
|
|
result := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id != target {
|
|
result = append(result, id)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- core operations ---
|
|
|
|
// createSprite creates a new sprite via the API (explicit POST, not lazy).
|
|
func (r *SpritesExecutor) createSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) {
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("creating sprite via API", zap.String("sprite", name))
|
|
sprite, err := client.CreateSprite(stepCtx, name, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create sprite: %w", err)
|
|
}
|
|
return sprite, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) uploadAgentctl(ctx context.Context, sprite *sprites.Sprite) error {
|
|
binaryPath, err := r.agentctlResolver.ResolveLinuxBinary()
|
|
if err != nil {
|
|
return fmt.Errorf("agentctl binary not found: %w", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(binaryPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read agentctl binary: %w", err)
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("uploading agentctl binary", zap.Int("size_bytes", len(data)))
|
|
|
|
// Upload via sprites filesystem API (HTTP PUT, much faster than stdin pipe for large binaries).
|
|
if err := r.writeFileWithRetry(stepCtx, sprite, "/usr/local/bin/agentctl", data, 0o755); err != nil {
|
|
return fmt.Errorf("failed to upload agentctl: %w", err)
|
|
}
|
|
|
|
// Verify the binary is present and executable.
|
|
if _, err := sprite.CommandContext(stepCtx, "test", "-x", "/usr/local/bin/agentctl").Output(); err != nil {
|
|
return fmt.Errorf("agentctl verification failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runPrepareScript resolves the prepare script with scriptengine and executes it,
|
|
// streaming stdout/stderr output through the onOutput callback.
|
|
func (r *SpritesExecutor) runPrepareScript(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
onOutput func(string),
|
|
) error {
|
|
script := r.resolvePrepareScript(req)
|
|
if script == "" {
|
|
r.logger.Debug("no prepare script configured, skipping")
|
|
return nil
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("running prepare script")
|
|
cmd := sprite.CommandContext(stepCtx, "sh", "-c", script)
|
|
cmd.Env = r.buildSpriteEnv(req.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start prepare script: %w", err)
|
|
}
|
|
|
|
// Stream stdout and stderr concurrently (io.MultiReader is sequential,
|
|
// which blocks stderr until stdout EOF — bad for git progress output).
|
|
var outputBuf strings.Builder
|
|
var outputMu sync.Mutex
|
|
emitOutput := func(chunk []byte) {
|
|
outputMu.Lock()
|
|
outputBuf.Write(chunk)
|
|
if onOutput != nil {
|
|
onOutput(lastLines(outputBuf.String(), spriteOutputMaxLines))
|
|
}
|
|
outputMu.Unlock()
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
readStream := func(r io.Reader) {
|
|
defer wg.Done()
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, readErr := r.Read(buf)
|
|
if n > 0 {
|
|
emitOutput(buf[:n])
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
wg.Add(2)
|
|
go readStream(stdout)
|
|
go readStream(stderr)
|
|
|
|
waitErr := cmd.Wait()
|
|
wg.Wait() // ensure all output is collected
|
|
|
|
if waitErr != nil {
|
|
return fmt.Errorf("prepare script failed: %w\n%s", waitErr, lastLines(outputBuf.String(), spriteOutputMaxLines))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolvePrepareScript builds the resolved prepare script using scriptengine.
|
|
func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string {
|
|
script := getMetadataString(req.Metadata, MetadataKeySetupScript)
|
|
if script == "" {
|
|
script = DefaultPrepareScript("sprites")
|
|
}
|
|
if script == "" {
|
|
return ""
|
|
}
|
|
|
|
resolver := scriptengine.NewResolver().
|
|
WithProvider(scriptengine.WorkspaceProvider(spritesWorkspacePath)).
|
|
WithProvider(scriptengine.AgentctlProvider(r.agentctlPort, spritesWorkspacePath)).
|
|
WithProvider(scriptengine.GitIdentityProvider(req.Metadata)).
|
|
WithProvider(scriptengine.RepositoryProvider(
|
|
req.Metadata,
|
|
req.Env,
|
|
getGitRemoteURL,
|
|
r.injectTokenIntoURL,
|
|
))
|
|
|
|
return resolver.Resolve(script)
|
|
}
|
|
|
|
func (r *SpritesExecutor) waitForHealth(ctx context.Context, sprite *sprites.Sprite) error {
|
|
deadline := time.Now().Add(spriteHealthTimeout)
|
|
healthURL := fmt.Sprintf("http://localhost:%d/health", r.agentctlPort)
|
|
|
|
for time.Now().Before(deadline) {
|
|
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
out, err := sprite.CommandContext(checkCtx, "curl", "-sf", healthURL).Output()
|
|
cancel()
|
|
|
|
if err == nil && len(out) > 0 {
|
|
r.logger.Debug("agentctl is healthy in sprite")
|
|
return nil
|
|
}
|
|
time.Sleep(spriteHealthRetryWait)
|
|
}
|
|
return fmt.Errorf("agentctl did not become healthy within %v", spriteHealthTimeout)
|
|
}
|
|
|
|
func (r *SpritesExecutor) setupPortForwarding(
|
|
_ context.Context,
|
|
sprite *sprites.Sprite,
|
|
spriteName, instanceID string,
|
|
remotePort int,
|
|
) (int, error) {
|
|
localPort, err := getFreePort()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get free port: %w", err)
|
|
}
|
|
|
|
r.logger.Debug("setting up port forwarding",
|
|
zap.Int("local_port", localPort),
|
|
zap.Int("remote_port", remotePort))
|
|
|
|
proxyCtx, cancel := context.WithCancel(context.Background())
|
|
session, err := sprite.ProxyPort(proxyCtx, localPort, remotePort)
|
|
if err != nil {
|
|
cancel()
|
|
return 0, fmt.Errorf("port forwarding failed: %w", err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.proxies[instanceID] = &SpritesProxySession{
|
|
spriteName: spriteName,
|
|
localPort: localPort,
|
|
proxySession: session,
|
|
cancel: cancel,
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
return localPort, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) cleanupOnFailure(_ context.Context, sprite *sprites.Sprite, instanceID string, destroySprite bool) {
|
|
if sprite == nil {
|
|
return
|
|
}
|
|
r.logger.Warn("cleaning up sprite after failure", zap.String("instance_id", instanceID))
|
|
|
|
r.mu.Lock()
|
|
if proxy, ok := r.proxies[instanceID]; ok {
|
|
r.closeProxySession(proxy)
|
|
delete(r.proxies, instanceID)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
if !destroySprite {
|
|
r.logger.Info("preserving sprite during cleanup (reconnect flow)",
|
|
zap.String("instance_id", instanceID))
|
|
return
|
|
}
|
|
|
|
if err := sprite.Destroy(); err != nil {
|
|
r.logger.Warn("failed to destroy sprite during cleanup", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) closeProxySession(proxy *SpritesProxySession) {
|
|
if proxy == nil {
|
|
return
|
|
}
|
|
if proxy.cancel != nil {
|
|
proxy.cancel()
|
|
}
|
|
if proxy.proxySession != nil {
|
|
_ = proxy.proxySession.Close()
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) applyNetworkPolicy(
|
|
ctx context.Context,
|
|
client *sprites.Client,
|
|
spriteName string,
|
|
req *ExecutorCreateRequest,
|
|
) error {
|
|
rulesJSON, _ := req.Metadata["sprites_network_policy_rules"].(string)
|
|
if rulesJSON == "" {
|
|
return nil
|
|
}
|
|
|
|
var rules []sprites.NetworkPolicyRule
|
|
if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil {
|
|
return fmt.Errorf("failed to parse network policy rules: %w", err)
|
|
}
|
|
if len(rules) == 0 {
|
|
return nil
|
|
}
|
|
|
|
policyCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Info("applying network policy from profile",
|
|
zap.String("sprite_name", spriteName),
|
|
zap.Int("rule_count", len(rules)))
|
|
|
|
return client.UpdateNetworkPolicy(policyCtx, spriteName, &sprites.NetworkPolicy{Rules: rules})
|
|
}
|
|
|
|
// --- utility helpers ---
|
|
|
|
func (r *SpritesExecutor) injectTokenIntoURL(remoteURL string, env map[string]string) string {
|
|
token := env["GITHUB_TOKEN"]
|
|
if token == "" {
|
|
return remoteURL
|
|
}
|
|
if converted := rewriteGitHubSSHToHTTPS(remoteURL); converted != "" {
|
|
remoteURL = converted
|
|
}
|
|
if strings.HasPrefix(remoteURL, "https://") {
|
|
return strings.Replace(remoteURL, "https://", "https://"+token+"@", 1)
|
|
}
|
|
return remoteURL
|
|
}
|
|
|
|
func rewriteGitHubSSHToHTTPS(remoteURL string) string {
|
|
const (
|
|
sshPrefixA = "git@github.com:"
|
|
sshPrefixB = "ssh://git@github.com/"
|
|
)
|
|
switch {
|
|
case strings.HasPrefix(remoteURL, sshPrefixA):
|
|
return "https://github.com/" + strings.TrimPrefix(remoteURL, sshPrefixA)
|
|
case strings.HasPrefix(remoteURL, sshPrefixB):
|
|
return "https://github.com/" + strings.TrimPrefix(remoteURL, sshPrefixB)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) writeFileWithRetry(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
path string,
|
|
data []byte,
|
|
mode os.FileMode,
|
|
) error {
|
|
backoff := 700 * time.Millisecond
|
|
var lastErr error
|
|
for attempt := 1; attempt <= spriteUploadMaxRetries+1; attempt++ {
|
|
err := sprite.Filesystem().WriteFileContext(ctx, path, data, mode)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
if attempt > spriteUploadMaxRetries || !isTransientUploadError(err) || ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
jitter := time.Duration(rand.Intn(300)) * time.Millisecond
|
|
wait := backoff + jitter
|
|
r.logger.Warn("retrying sprite file upload after transient error",
|
|
zap.String("path", path),
|
|
zap.Int("attempt", attempt),
|
|
zap.Duration("retry_in", wait),
|
|
zap.Error(err))
|
|
time.Sleep(wait)
|
|
backoff *= 2
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func isTransientUploadError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return true
|
|
}
|
|
|
|
msg := strings.ToLower(err.Error())
|
|
if status := extractUploadHTTPStatus(msg); status != 0 {
|
|
if status == 408 || status == 429 || status >= 500 {
|
|
return true
|
|
}
|
|
}
|
|
return strings.Contains(msg, "client.timeout exceeded while awaiting headers") ||
|
|
strings.Contains(msg, "request canceled") ||
|
|
strings.Contains(msg, "timeout") ||
|
|
strings.Contains(msg, "text file busy") ||
|
|
strings.Contains(msg, "connection reset") ||
|
|
strings.Contains(msg, "temporary")
|
|
}
|
|
|
|
func extractUploadHTTPStatus(msg string) int {
|
|
matches := uploadHTTPStatusRE.FindStringSubmatch(msg)
|
|
if len(matches) < 2 {
|
|
return 0
|
|
}
|
|
code, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return code
|
|
}
|
|
|
|
func (r *SpritesExecutor) buildSpriteEnv(env map[string]string) []string {
|
|
result := make([]string, 0, len(env))
|
|
for k, v := range env {
|
|
result = append(result, k+"="+v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- step reporting helpers ---
|
|
|
|
// newStepReporter creates a reporting function that calls OnProgress if non-nil.
|
|
func newStepReporter(onProgress PrepareProgressCallback) func(PrepareStep, int) {
|
|
return func(step PrepareStep, idx int) {
|
|
if onProgress != nil {
|
|
onProgress(step, idx, spritesTotalSteps)
|
|
}
|
|
}
|
|
}
|
|
|
|
// lastLines returns the last n lines of s.
|
|
func lastLines(s string, n int) string {
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) <= n {
|
|
return s
|
|
}
|
|
return strings.Join(lines[len(lines)-n:], "\n")
|
|
}
|
|
|
|
// getFreePort finds an available local port.
|
|
func getFreePort() (int, error) {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() { _ = l.Close() }()
|
|
return l.Addr().(*net.TCPAddr).Port, nil
|
|
}
|