Class: AgentHarness::DockerCommandExecutor

Inherits:
CommandExecutor show all
Defined in:
lib/agent_harness/docker_command_executor.rb

Overview

Executes commands inside a Docker container

Wraps commands with ‘docker exec` so they run inside the specified container rather than on the host.

Examples:

Basic usage

executor = AgentHarness::DockerCommandExecutor.new(container_id: "abc123")
result = executor.execute(["python", "script.py"])

With environment variables

result = executor.execute("echo $FOO", env: { "FOO" => "bar" })

Constant Summary

Constants inherited from CommandExecutor

CommandExecutor::PREPARATION_CLEANUP_GRACE_PERIOD, CommandExecutor::PREPARATION_LOCK_POLL_INTERVAL, CommandExecutor::PREPARATION_LOCK_ROOT

Instance Attribute Summary collapse

Attributes inherited from CommandExecutor

#logger

Instance Method Summary collapse

Methods inherited from CommandExecutor

#available?

Constructor Details

#initialize(container_id:, logger: nil) ⇒ DockerCommandExecutor

Initialize the Docker command executor

Parameters:

  • container_id (String)

    the Docker container ID or name

  • logger (Logger, nil) (defaults to: nil)

    optional logger

Raises:



25
26
27
28
29
30
31
32
33
# File 'lib/agent_harness/docker_command_executor.rb', line 25

def initialize(container_id:, logger: nil)
  unless container_id.is_a?(String) && !container_id.strip.empty?
    raise ArgumentError, "container_id cannot be nil or blank"
  end

  super(logger: logger)
  @container_id = container_id
  validate_docker!
end

Instance Attribute Details

#container_idObject (readonly)

Returns the value of attribute container_id.



18
19
20
# File 'lib/agent_harness/docker_command_executor.rb', line 18

def container_id
  @container_id
end

Instance Method Details

#execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil, **execution_options) ⇒ Result

Execute a command inside the Docker container

Wraps the given command with ‘docker exec` and delegates to the parent class for actual process execution.

Parameters:

  • command (Array<String>, String)

    command to execute

  • timeout (Integer, nil) (defaults to: nil)

    timeout in seconds

  • idle_timeout (Integer, Float, nil) (defaults to: nil)

    idle timeout in seconds based on output activity

  • env (Hash) (defaults to: {})

    environment variables to set in the container

  • stdin_data (String, nil) (defaults to: nil)

    data to send to stdin

  • preparation (ExecutionPreparation, nil) (defaults to: nil)

    request-scoped bootstrap work to materialize inside the container before the main command runs

Returns:

  • (Result)

    execution result



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/agent_harness/docker_command_executor.rb', line 48

def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil, **execution_options)
  start_time = current_time
  normalized_command = normalize_command(command)
  command_name = normalized_command.first
  deadline = timeout_deadline(timeout)
  cleanup_steps = []
  execution_tracking = nil
  held_preparation_locks = acquire_preparation_locks(
    preparation,
    env: env,
    timeout: timeout,
    deadline: deadline,
    command_name: command_name
  )
  background_cleanup_scheduled = false

  apply_container_preparation(preparation, timeout: timeout, deadline: deadline, env: env, cleanup_steps: cleanup_steps)
  execution_tracking = if timeout && !preparation.nil? && !preparation.empty?
    build_container_execution_tracking(normalized_command, env: env)
  end
  docker_cmd = build_docker_command_for_execution(
    normalized_command,
    env: env,
    stdin_data: stdin_data,
    execution_tracking: execution_tracking
  )
  begin
    result = super(
      docker_cmd,
      timeout: remaining_timeout(deadline, timeout:, command_name: command_name),
      idle_timeout: idle_timeout,
      env: {},
      stdin_data: stdin_data,
      **execution_options
    )
  rescue IdleTimeoutError
    raise
  rescue TimeoutError
    schedule_container_cleanup_preparation(
      cleanup_steps,
      held_preparation_locks,
      command_name: command_name,
      termination_command: execution_tracking && execution_tracking[:terminate_command],
      finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
    )
    background_cleanup_scheduled = true
    held_preparation_locks = []
    raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
  end
  begin
    cleanup_container_preparation(
      cleanup_steps,
      timeout:,
      deadline: cleanup_deadline(deadline, timeout:),
      command_name: command_name
    )
    cleanup_container_execution_tracking(
      execution_tracking,
      timeout:,
      deadline: cleanup_deadline(deadline, timeout:),
      command_name: command_name
    )
    execution_tracking = nil
  rescue TimeoutError
    # The main command already finished; omit termination_command so
    # background cleanup does not TERM/KILL based on a stale PID file
    # that may have been reused by an unrelated process.
    schedule_container_cleanup_preparation(
      cleanup_steps,
      held_preparation_locks,
      command_name: command_name,
      termination_command: nil,
      finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
    )
    background_cleanup_scheduled = true
    held_preparation_locks = []
  end
  Result.new(
    stdout: result.stdout,
    stderr: result.stderr,
    exit_code: result.exit_code,
    duration: current_time - start_time
  )
ensure
  pending_exception = $!
  cleanup_pending = !cleanup_steps.nil? && !cleanup_steps.empty?
  tracking_cleanup_pending = !execution_tracking.nil?
  if !background_cleanup_scheduled && (cleanup_pending || tracking_cleanup_pending)
    begin
      cleanup_container_preparation(
        cleanup_steps,
        timeout:,
        deadline: cleanup_deadline(deadline, timeout:),
        command_name: command_name
      )
      cleanup_container_execution_tracking(
        execution_tracking,
        timeout:,
        deadline: cleanup_deadline(deadline, timeout:),
        command_name: command_name
      )
    rescue TimeoutError => e
      raise e if pending_exception.nil?

      if pending_exception.is_a?(TimeoutError)
        schedule_container_cleanup_preparation(
          cleanup_steps,
          held_preparation_locks,
          command_name: command_name,
          termination_command: execution_tracking && execution_tracking[:terminate_command],
          finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
        )
        background_cleanup_scheduled = true
        held_preparation_locks = []
      else
        # Preserve the original non-timeout exception; surface that
        # cleanup also timed out so callers know bootstrap state may
        # have leaked.
        raise pending_exception.class,
          "#{pending_exception.message} (cleanup also failed: #{e.message})"
      end
    rescue => e
      raise e if pending_exception.nil?

      # Surface cleanup failures even when unwinding from another exception,
      # so callers know request-scoped bootstrap state may have leaked.
      raise pending_exception.class,
        "#{pending_exception.message} (cleanup also failed: #{e.message})"
    end
  end
  unless background_cleanup_scheduled || held_preparation_locks.nil? || held_preparation_locks.empty?
    release_preparation_locks(held_preparation_locks)
  end
end

#which(binary) ⇒ String?

Check if a binary exists inside the container

Parameters:

  • binary (String)

    binary name

Returns:

  • (String, nil)

    full path or nil



187
188
189
190
# File 'lib/agent_harness/docker_command_executor.rb', line 187

def which(binary)
  result = execute(["which", binary], timeout: 5)
  result.success? ? result.stdout.strip : nil
end