Files
osmedeus/internal/runner/docker_runner.go
T
j3ssie bb15efdf60 feat: db replay and snapshot improvements
- Add replayDBOperations to reinitialize databases from imported workspaces, with proper parameter resolution and pre-condition skipping
- Implement ISO 8601 timestamp format for snapshots (2026-02-13T18-20-34Z) instead of Unix epoch
- Add --include-heavy flag to include large fields (raw_response, screenshot, blob_content) in database queries
- Fix asset table default columns (url, status_code, content_length, title) for better UX
- Skip heavy initialization for lightweight commands (help, version, completion) to avoid ~50MB overhead
- Optimize database function execution via lazy config initialization
- Fix binary installation via Nix to copy already-installed binaries to binaries folder
2026-02-13 09:06:51 +07:00

385 lines
9.7 KiB
Go

package runner
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"github.com/j3ssie/osmedeus/v5/internal/core"
)
// DockerRunner executes commands inside a Docker container
type DockerRunner struct {
config *core.RunnerConfig
binaryPath string
containerID string
remoteDir string
onPIDStart PIDCallback
onPIDEnd PIDCallback
}
// NewDockerRunner creates a new Docker runner
func NewDockerRunner(config *core.RunnerConfig, binaryPath string) (*DockerRunner, error) {
if config.Image == "" {
return nil, fmt.Errorf("docker runner requires image to be specified")
}
return &DockerRunner{
config: config,
binaryPath: binaryPath,
remoteDir: "/root/.osmedeus-remote",
}, nil
}
// Setup prepares the Docker container
func (r *DockerRunner) Setup(ctx context.Context) error {
// Check if Docker is available
if _, err := exec.LookPath("docker"); err != nil {
return fmt.Errorf("docker not found in PATH: %w", err)
}
// Pull image if needed (ignore errors if image already exists)
pullCmd := exec.CommandContext(ctx, "docker", "pull", r.config.Image)
_ = pullCmd.Run() // Ignore error, image might already exist locally
if r.config.Persistent {
// Create and start a persistent container
return r.startPersistentContainer(ctx)
}
return nil
}
// startPersistentContainer creates and starts a long-running container
func (r *DockerRunner) startPersistentContainer(ctx context.Context) error {
args := []string{"run", "-d", "--rm"}
// Add environment variables
for k, v := range r.config.Env {
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
}
// Add volume mounts
for _, vol := range r.config.Volumes {
args = append(args, "-v", vol)
}
// Add network mode
if r.config.Network != "" {
args = append(args, "--network", r.config.Network)
}
// Add working directory
if r.config.WorkDir != "" {
args = append(args, "-w", r.config.WorkDir)
}
// Add image and keep-alive command
args = append(args, r.config.Image, "tail", "-f", "/dev/null")
cmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to start container: %w, stderr: %s", err, stderr.String())
}
r.containerID = strings.TrimSpace(stdout.String())
if r.containerID == "" {
return fmt.Errorf("failed to get container ID")
}
// Copy binary to container if it exists
if r.binaryPath != "" {
if err := r.copyBinaryToContainer(ctx); err != nil {
// Non-fatal, log and continue
fmt.Printf("Warning: failed to copy binary to container: %v\n", err)
}
}
return nil
}
// copyBinaryToContainer copies the osmedeus binary to the container
func (r *DockerRunner) copyBinaryToContainer(ctx context.Context) error {
if r.containerID == "" {
return fmt.Errorf("no container running")
}
// Create remote directory
mkdirCmd := exec.CommandContext(ctx, "docker", "exec", r.containerID, "mkdir", "-p", r.remoteDir)
if err := mkdirCmd.Run(); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
// Copy binary
destPath := filepath.Join(r.remoteDir, "osmedeus")
copyCmd := exec.CommandContext(ctx, "docker", "cp", r.binaryPath, fmt.Sprintf("%s:%s", r.containerID, destPath))
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy binary: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "docker", "exec", r.containerID, "chmod", "+x", destPath)
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
return nil
}
// Execute runs a command in the Docker container
func (r *DockerRunner) Execute(ctx context.Context, command string) (*CommandResult, error) {
if r.config.Persistent && r.containerID != "" {
return r.execInContainer(ctx, command)
}
return r.runEphemeral(ctx, command)
}
// execInContainer runs a command in an existing container using docker exec
func (r *DockerRunner) execInContainer(ctx context.Context, command string) (*CommandResult, error) {
args := []string{"exec"}
// Add working directory if specified
if r.config.WorkDir != "" {
args = append(args, "-w", r.config.WorkDir)
}
// @NOTE: This is intentional - the workflow engine is designed to execute arbitrary
// commands from YAML workflow definitions inside Docker containers.
args = append(args, r.containerID, "sh", "-c", command)
cmd := exec.Command("docker", args...)
// Create new process group so we can kill all children on interrupt
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout := NewLimitedBuffer(MaxOutputSize)
stderr := NewLimitedBuffer(MaxStderrSize)
cmd.Stdout = stdout
cmd.Stderr = stderr
// Start the command
if err := cmd.Start(); err != nil {
return &CommandResult{
Output: "",
ExitCode: -1,
Error: err,
}, nil
}
// Track PID for cancellation support
pid := cmd.Process.Pid
if r.onPIDStart != nil {
r.onPIDStart(pid)
}
// Wait for command completion or context cancellation
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
// Context cancelled - kill entire process group
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
<-done
// Notify PID ended
if r.onPIDEnd != nil {
r.onPIDEnd(pid)
}
return &CommandResult{
Output: combineOutput(stdout, stderr),
ExitCode: -1,
Error: ctx.Err(),
}, nil
case err := <-done:
// Notify PID ended
if r.onPIDEnd != nil {
r.onPIDEnd(pid)
}
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
}
return &CommandResult{
Output: combineOutput(stdout, stderr),
ExitCode: exitCode,
Error: err,
}, nil
}
}
// runEphemeral runs a command in a new ephemeral container (docker run --rm)
func (r *DockerRunner) runEphemeral(ctx context.Context, command string) (*CommandResult, error) {
args := []string{"run", "--rm"}
// Add environment variables
for k, v := range r.config.Env {
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
}
// Add volume mounts
for _, vol := range r.config.Volumes {
args = append(args, "-v", vol)
}
// Add network mode
if r.config.Network != "" {
args = append(args, "--network", r.config.Network)
}
// Add working directory
if r.config.WorkDir != "" {
args = append(args, "-w", r.config.WorkDir)
}
// Add image and command
// @NOTE: This is intentional - the workflow engine is designed to execute arbitrary
// commands from YAML workflow definitions inside ephemeral Docker containers.
args = append(args, r.config.Image, "sh", "-c", command)
cmd := exec.Command("docker", args...)
// Create new process group so we can kill all children on interrupt
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout := NewLimitedBuffer(MaxOutputSize)
stderr := NewLimitedBuffer(MaxStderrSize)
cmd.Stdout = stdout
cmd.Stderr = stderr
// Set environment from host if needed
cmd.Env = os.Environ()
// Start the command
if err := cmd.Start(); err != nil {
return &CommandResult{
Output: "",
ExitCode: -1,
Error: err,
}, nil
}
// Track PID for cancellation support
pid := cmd.Process.Pid
if r.onPIDStart != nil {
r.onPIDStart(pid)
}
// Wait for command completion or context cancellation
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
// Context cancelled - kill entire process group
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
<-done
// Notify PID ended
if r.onPIDEnd != nil {
r.onPIDEnd(pid)
}
return &CommandResult{
Output: combineOutput(stdout, stderr),
ExitCode: -1,
Error: ctx.Err(),
}, nil
case err := <-done:
// Notify PID ended
if r.onPIDEnd != nil {
r.onPIDEnd(pid)
}
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
}
return &CommandResult{
Output: combineOutput(stdout, stderr),
ExitCode: exitCode,
Error: err,
}, nil
}
}
// SetPIDCallbacks sets callbacks for process lifecycle events
func (r *DockerRunner) SetPIDCallbacks(onStart, onEnd PIDCallback) {
r.onPIDStart = onStart
r.onPIDEnd = onEnd
}
// Cleanup stops and removes the container
func (r *DockerRunner) Cleanup(ctx context.Context) error {
if r.config.Persistent && r.containerID != "" {
// Stop the container (--rm flag will auto-remove it)
stopCmd := exec.CommandContext(ctx, "docker", "stop", r.containerID)
_ = stopCmd.Run() // Ignore error
r.containerID = ""
}
return nil
}
// CopyFromRemote copies a file from the container to the local host using docker cp
func (r *DockerRunner) CopyFromRemote(ctx context.Context, remotePath, localPath string) error {
if r.containerID == "" {
return fmt.Errorf("no container ID available for file copy")
}
// Ensure parent directory exists
if dir := filepath.Dir(localPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
// docker cp <containerID>:<remotePath> <localPath>
cmd := exec.CommandContext(ctx, "docker", "cp",
fmt.Sprintf("%s:%s", r.containerID, remotePath), localPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker cp failed: %w, output: %s", err, string(output))
}
return nil
}
// Type returns the runner type
func (r *DockerRunner) Type() core.RunnerType {
return core.RunnerTypeDocker
}
// IsRemote returns true since Docker container is considered remote
func (r *DockerRunner) IsRemote() bool {
return true
}
// GetContainerID returns the current container ID (for persistent mode)
func (r *DockerRunner) GetContainerID() string {
return r.containerID
}