mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-24 08:32:27 +02:00
- 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
385 lines
9.7 KiB
Go
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
|
|
}
|