lemans 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/lemans/agent.rb CHANGED
@@ -15,6 +15,9 @@ module Lemans
15
15
 
16
16
  attr_reader :profile, :model
17
17
 
18
+ # Whether #run can go on from the history (raw result) of an interrupted run
19
+ def self.recoverable? = false
20
+
18
21
  def initialize(profile:, model: nil)
19
22
  @profile = profile
20
23
 
@@ -28,7 +31,7 @@ module Lemans
28
31
  def install(_task, _environment) = nil
29
32
 
30
33
  # Run the task.
31
- def run(task, environment)
34
+ def run(task, environment, history: nil)
32
35
  raise NotImplementedError
33
36
  end
34
37
 
@@ -21,9 +21,13 @@ module Lemans
21
21
  cost_limit: :cost_ceiling_reached
22
22
  }.freeze
23
23
 
24
- def run(task, environment)
25
- run_result = obtain_result(task, environment)
24
+ def self.recoverable? = true
25
+
26
+ def run(task, environment, history: nil)
27
+ history &&= ::Miniswen::Agent::Result.from_h(JSON.parse(history))
28
+ run_result = obtain_result(task, environment, history)
26
29
  trajectory = trajectory_for(run_result)
30
+ raw_result = raw_result_for(run_result)
27
31
 
28
32
  # A failed model call is still an answer: the trial saves the
29
33
  # trajectory as evidence before failing.
@@ -39,16 +43,17 @@ module Lemans
39
43
 
40
44
  private
41
45
 
42
- def obtain_result(task, environment)
46
+ def obtain_result(task, environment, history)
43
47
  agent = agent_for(environment)
44
48
  begin
45
- agent.run(task.instruction)
49
+ agent.run(task.instruction, history:)
46
50
  rescue InfrastructureError, ::Miniswen::InfrastructureError => e
47
51
  agent.partial_result(e.message)
48
52
  end
49
53
  end
50
54
 
51
- def raw_result = nil
55
+ # The run as miniswen records it, signatures included: what a recovery continues from
56
+ def raw_result_for(run_result) = JSON.generate(run_result.to_h)
52
57
 
53
58
  def agent_for(environment)
54
59
  raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
@@ -13,6 +13,7 @@ module Lemans
13
13
  class MiniswenInstalled < Miniswen
14
14
  NAME = "miniswen-installed"
15
15
  RESULTS_PATH = "/tmp/lemans-miniswen.result.json"
16
+ HISTORY_PATH = "/tmp/lemans-miniswen.history.json"
16
17
  INSTALL_TIMEOUT_SEC = 300
17
18
  # The CLI enforces max-time itself, but between steps only: a command
18
19
  # started just before the deadline runs to its own exec timeout first,
@@ -31,8 +32,9 @@ module Lemans
31
32
 
32
33
  # An in-sandbox run self-reports: everything but the verifier's reward
33
34
  # comes from a file the sandbox wrote.
34
- def obtain_result(task, environment)
35
- run = environment.exec(command_for(task), timeout: outer_timeout, env: provider_env(environment))
35
+ def obtain_result(task, environment, history)
36
+ upload_history(environment, history) if history
37
+ run = environment.exec(command_for(task, history:), timeout: outer_timeout, env: provider_env(environment))
36
38
 
37
39
  begin
38
40
  Tempfile.create(%w[miniswen .result.json]) do |file|
@@ -47,7 +49,7 @@ module Lemans
47
49
  end
48
50
  end
49
51
 
50
- attr_reader :raw_result
52
+ def raw_result_for(_run_result) = @raw_result
51
53
 
52
54
  def outer_timeout = profile.timeout + profile.exec_timeout + EXEC_SLACK_SEC
53
55
 
@@ -64,9 +66,18 @@ module Lemans
64
66
  policy.mode == "allowlist" ? policy.domains : []
65
67
  end
66
68
 
67
- def command_for(task)
69
+ def upload_history(environment, history)
70
+ Tempfile.create(%w[miniswen .history.json]) do |file|
71
+ file.write(JSON.generate(history.to_h))
72
+ file.flush
73
+ environment.upload(file.path, HISTORY_PATH)
74
+ end
75
+ end
76
+
77
+ def command_for(task, history: nil)
68
78
  argv = [ "miniswen", "-q", "--no-refresh-registry", "--jail",
69
- "-m", model.to_s, "-p", task.instruction,
79
+ "-m", model.to_s,
80
+ *(history ? [ "--continue-from", HISTORY_PATH ] : [ "-p", task.instruction ]),
70
81
  "--results-path", RESULTS_PATH,
71
82
  "--max-steps", profile.step_limit, "--max-time", profile.timeout.to_i,
72
83
  "--exec-timeout", profile.exec_timeout.to_i,
@@ -7,7 +7,7 @@ module Lemans
7
7
  class Nop < Agent
8
8
  NAME = "nop"
9
9
 
10
- def run(_task, _environment)
10
+ def run(_task, _environment, history: nil)
11
11
  Response.new(outcome: Result::Outcome.new(:completed), usage: Result::Usage.zero)
12
12
  end
13
13
  end
@@ -13,7 +13,7 @@ module Lemans
13
13
  ENTRYPOINT = "solve.sh"
14
14
  PATCH = "solution.patch"
15
15
 
16
- def run(task, environment)
16
+ def run(task, environment, history: nil)
17
17
  files = task.solution_files
18
18
  if files.empty?
19
19
  raise ConfigError, "#{task.name}: no solution/ to run — the oracle has nothing to prove" if
data/lib/lemans/agents.rb CHANGED
@@ -11,11 +11,13 @@ module Lemans
11
11
  "miniswen-installed" => "MiniswenInstalled"
12
12
  }.freeze
13
13
 
14
- def self.build(name, profile:, model: nil)
14
+ def self.build(name, profile:, model: nil) = lookup(name).new(profile: profile, model: model)
15
+
16
+ def self.lookup(name)
15
17
  constant = REGISTRY[name] or
16
18
  raise ConfigError, "unknown agent #{name.inspect} (known: #{REGISTRY.keys.join(", ")})"
17
19
 
18
- const_get(constant).new(profile: profile, model: model)
20
+ const_get(constant)
19
21
  end
20
22
  end
21
23
  end
data/lib/lemans/cli.rb CHANGED
@@ -78,49 +78,80 @@ module Lemans
78
78
  return
79
79
  end
80
80
 
81
- reporter =
82
- if interactive?
83
- BoardReporter.new(tasks: tasks.map(&:name), models: config.models,
84
- attempts: config.attempts, total: runner.attempts.size)
85
- else
86
- ProgressReporter.new(shell:, tasks: tasks.map(&:name))
87
- end
81
+ execute(runner, store, tasks)
82
+ rescue ConfigError => e
83
+ raise Thor::Error, "lemans: #{e.message}"
84
+ rescue Interrupt
85
+ say ""
86
+ exit 130
87
+ end
88
88
 
89
- reporter.start
89
+ desc "restart RUN...", "Continue failed multistep runs from their last settled step in new runs"
90
+ long_desc <<~DESC
91
+ RUN is a run directory or a trial id; every run named restarts under the same options. The new run replays the settled steps' agent patches in a
92
+ fresh sandbox and starts at the next step; the failed run stays as it is. --recover also replays
93
+ the failed step's partial patch and lets the agent go on from its history; --reverify replays the
94
+ graded step's patch and runs its verification again.
95
+ DESC
96
+ option :bench, default: ".", desc: "Directory holding bench.yml"
97
+ option :runs_dir, default: "./runs", desc: "Directory holding the runs; the new runs go there too"
98
+ option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once (default: the bench's)"
99
+ option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
100
+ option :max_output_tokens, type: :numeric, banner: "TOKENS",
101
+ desc: "Cap the agent's output per model call (default: the provider's)"
102
+ option :recover, type: :boolean, default: false,
103
+ desc: "Continue the failed step's agent session from its saved history"
104
+ option :reverify, type: :boolean, default: false,
105
+ desc: "Grade the last verified step again (with the current tests) and go on from there"
106
+ option :allow_scored, type: :boolean, default: false, desc: "Restart a scored run (--reverify always may)"
107
+ def restart(*runs)
108
+ raise Thor::Error, "lemans: name the run(s) to restart" if runs.empty?
109
+ raise Thor::Error, "lemans: --recover and --reverify exclude each other" if options[:recover] && options[:reverify]
90
110
 
91
- summary = runner.run(reporter)
111
+ Miniswen.refresh_registry!
92
112
 
93
- say ""
94
- say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
95
- print_report Report.load(store)
113
+ store = Stores::FS.new(options[:runs_dir], filterer: SecretsFilter.default)
114
+ ids = runs.map { File.basename(it) }.uniq
115
+ found = store.fetch.select { ids.include?(it.id) }.to_h { [ it.id, it ] }
116
+ missing = ids - found.keys
117
+ raise Thor::Error, "lemans: no run #{missing.join(", ")} under #{options[:runs_dir]}" if missing.any?
96
118
 
97
- exit 130 if summary.status == :interrupted
98
- exit 1 if summary.status == :invalid
119
+ sources = found.values_at(*ids)
120
+
121
+ # The board lays out every model the runs used, as wide as their highest attempt
122
+ config = Config.load_file(options[:bench])
123
+ config.load_options(**options.transform_keys(&:to_sym), model: sources.map(&:model).uniq,
124
+ attempts: sources.filter_map(&:index).max)
125
+
126
+ tasks = filter_tasks(config.tasks, name: sources.map(&:task).uniq)
127
+
128
+ mode = (:recover if options[:recover]) || (:reverify if options[:reverify])
129
+ runner = Runner.new(config, tasks, store:, restarts: sources, restart_mode: mode, allow_scored: options[:allow_scored])
130
+
131
+ execute(runner, store, tasks)
99
132
  rescue ConfigError => e
100
133
  raise Thor::Error, "lemans: #{e.message}"
101
134
  rescue Interrupt
102
135
  say ""
103
136
  exit 130
104
- ensure
105
- reporter&.stop
106
137
  end
107
138
 
108
- desc "clobber", "Delete run results"
109
- option :runs_dir, default: "./runs", desc: "Directory holding run directories"
139
+ desc "clobber [RUNS_DIR]", "Delete run results"
140
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
110
141
  option :task, desc: "Only these tasks' runs", repeatable: true
111
142
  option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
112
143
  option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
113
144
  option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
114
- def clobber
115
- store = Stores::FS.new(options[:runs_dir])
145
+ def clobber(runs_dir = options[:runs_dir])
146
+ store = Stores::FS.new(runs_dir)
116
147
  clobber = Clobber.new(store, tasks: options[:task], ttl: options[:ttl], invalid: options[:invalid])
117
148
 
118
149
  doomed = clobber.matches
119
- return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
150
+ return say "lemans: nothing to clobber under #{runs_dir}" if doomed.empty?
120
151
 
121
152
  unless options[:force]
122
153
  doomed.each { say it.id }
123
- return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
154
+ return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{runs_dir}? [y/N]")
124
155
  end
125
156
 
126
157
  removed = clobber.execute!
@@ -129,15 +160,15 @@ module Lemans
129
160
  raise Thor::Error, "lemans: #{e.message}"
130
161
  end
131
162
 
132
- desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
163
+ desc "regrade [RUNS_DIR]", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
133
164
  option :bench, default: ".", desc: "Directory holding bench.yml"
134
165
  option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
135
- option :runs_dir, default: "./runs", desc: "Directory holding run directories"
166
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
136
167
  option :mapping, banner: "PATH",
137
168
  desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
138
169
  "instead of reading verification_test.rb"
139
- def regrade
140
- store = Stores::FS.new(options[:runs_dir])
170
+ def regrade(runs_dir = options[:runs_dir])
171
+ store = Stores::FS.new(runs_dir)
141
172
  tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
142
173
  raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
143
174
 
@@ -153,14 +184,14 @@ module Lemans
153
184
  end
154
185
 
155
186
  say ""
156
- say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
187
+ say_status :report, "collecting results from #{runs_dir}", :cyan
157
188
  print_report Report.load(store, names: tasks.map(&:name))
158
189
  rescue ConfigError => e
159
190
  raise Thor::Error, "lemans: #{e.message}"
160
191
  end
161
192
 
162
- desc "report", "Summarize run results as a table or CSV"
163
- option :runs_dir, default: "runs", desc: "Directory holding run directories"
193
+ desc "report [RUNS_DIR]", "Summarize run results as a table or CSV"
194
+ option :runs_dir, default: "runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
164
195
  option :tag, desc: "Only runs whose result carries this tag", repeatable: true
165
196
  option :task, desc: "Only these tasks' runs", repeatable: true
166
197
  option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
@@ -169,8 +200,8 @@ module Lemans
169
200
  option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
170
201
  desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
171
202
  option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
172
- def report
173
- store = Stores::FS.new(options[:runs_dir])
203
+ def report(runs_dir = options[:runs_dir])
204
+ store = Stores::FS.new(runs_dir)
174
205
  results = Report.load(store, tags: options[:tag], names: options[:task],
175
206
  metadata: Report.metadata_filter(options[:metadata]))
176
207
  raise Thor::Error, "lemans: no matching results found" if results.empty?
@@ -184,6 +215,29 @@ module Lemans
184
215
 
185
216
  private
186
217
 
218
+ def execute(runner, store, tasks)
219
+ reporter =
220
+ if interactive?
221
+ BoardReporter.new(tasks: tasks.map(&:name), models: runner.config.models,
222
+ attempts: runner.config.attempts, total: runner.attempts.size)
223
+ else
224
+ ProgressReporter.new(shell:, tasks: tasks.map(&:name))
225
+ end
226
+
227
+ reporter.start
228
+
229
+ summary = runner.run(reporter)
230
+
231
+ say ""
232
+ say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
233
+ print_report Report.load(store)
234
+
235
+ exit 130 if summary.status == :interrupted
236
+ exit 1 if summary.status == :invalid
237
+ ensure
238
+ reporter&.stop
239
+ end
240
+
187
241
  def filter_tasks(tasks, tags: nil, name: nil)
188
242
  tasks = tasks.dup
189
243
 
@@ -0,0 +1,4 @@
1
+ FROM ruby:3.4-alpine
2
+ COPY server.rb /server.rb
3
+ EXPOSE 3128
4
+ ENTRYPOINT ["ruby", "/server.rb"]
@@ -0,0 +1,49 @@
1
+ # Allowlist proxy
2
+
3
+ This is an HTTP proxy that lets clients reach the allowed hosts only. The Docker backend runs it for the `allowlist` network mode.
4
+
5
+ The proxy supports two request types:
6
+
7
+ - `CONNECT host:port` tunnels (https).
8
+ - Absolute-form requests (`GET http://host/path`) for plain http.
9
+
10
+ The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported.
11
+
12
+ ## How the Docker backend uses it
13
+
14
+ 1. Lemans puts the task container on an internal network. An internal network has no route out.
15
+ 2. Lemans connects the proxy container to the internal network and to `bridge`.
16
+ 3. Lemans sets `http_proxy` and `https_proxy` for each command in the task container.
17
+
18
+ A tool that ignores the proxy variables cannot reach the network.
19
+
20
+ When the allowed hosts change, lemans replaces the proxy container.
21
+
22
+ ## Run it standalone
23
+
24
+ Build the image:
25
+
26
+ ```sh
27
+ docker build --tag lemans-proxy lib/lemans/environments/docker/proxy
28
+ ```
29
+
30
+ Start the proxy. Give the allowed hosts as arguments:
31
+
32
+ ```sh
33
+ docker run --rm --publish 3128:3128 lemans-proxy rubygems.org index.rubygems.org
34
+ ```
35
+
36
+ Send requests through it:
37
+
38
+ ```sh
39
+ curl --proxy http://localhost:3128 https://rubygems.org # allowed
40
+ curl --proxy http://localhost:3128 https://example.com # 403 Forbidden
41
+ ```
42
+
43
+ To run it without Docker, use Ruby 3.4 or later:
44
+
45
+ ```sh
46
+ ruby lib/lemans/environments/docker/proxy/server.rb rubygems.org
47
+ ```
48
+
49
+ The `PORT` variable changes the listen port. The default port is 3128.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+
6
+ # An HTTP proxy that lets clients reach the allowed hosts only:
7
+ # CONNECT tunnels for https, absolute-form requests for plain http.
8
+ class AllowlistProxy
9
+ HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
10
+
11
+ def initialize(hosts, port: 3128)
12
+ @hosts = hosts.map(&:downcase)
13
+ @port = port
14
+ end
15
+
16
+ def run
17
+ server = TCPServer.new("0.0.0.0", @port)
18
+ $stdout.puts "listening on #{@port}: #{@hosts.join(", ")}"
19
+ $stdout.flush
20
+ Thread.report_on_exception = false
21
+ loop { Thread.new(server.accept) { handle(it) } }
22
+ end
23
+
24
+ private
25
+
26
+ def handle(client)
27
+ head = client.gets("\r\n\r\n") or return
28
+ request_line, *headers = head.split("\r\n")
29
+ verb, target, version = request_line.split(" ", 3)
30
+
31
+ verb == "CONNECT" ? tunnel(client, target) : forward(client, verb, target, version, headers)
32
+ rescue SystemCallError, IOError, SocketError, URI::Error
33
+ client.write("HTTP/1.1 502 Bad Gateway\r\n\r\n") rescue nil # rubocop:disable Style/RescueModifier
34
+ ensure
35
+ client.close
36
+ end
37
+
38
+ def tunnel(client, target)
39
+ host, port = target.split(":", 2)
40
+ return deny(client) unless allowed?(host)
41
+
42
+ upstream = Socket.tcp(host, port.to_i, connect_timeout: 10)
43
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n")
44
+ pump(client, upstream)
45
+ end
46
+
47
+ # One request per connection: the upstream is told to close after the response.
48
+ def forward(client, verb, target, version, headers)
49
+ uri = URI(target)
50
+ return deny(client) unless uri.is_a?(URI::HTTP) && allowed?(uri.host)
51
+
52
+ upstream = Socket.tcp(uri.host, uri.port, connect_timeout: 10)
53
+ headers = headers.grep_v(HOP_HEADERS)
54
+ upstream.write("#{verb} #{uri.request_uri} #{version}\r\n#{headers.join("\r\n")}\r\nConnection: close\r\n\r\n")
55
+ pump(client, upstream)
56
+ end
57
+
58
+ def allowed?(host) = @hosts.include?(host.to_s.downcase)
59
+
60
+ def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
61
+
62
+ def pump(client, upstream)
63
+ Thread.new { copy(client, upstream) }
64
+ copy(upstream, client)
65
+ ensure
66
+ upstream.close
67
+ end
68
+
69
+ # readpartial drains what gets already buffered, so a request body is not lost.
70
+ def copy(from, to)
71
+ loop { to.write(from.readpartial(65_536)) }
72
+ rescue EOFError, IOError, SystemCallError
73
+ nil
74
+ ensure
75
+ to.close_write rescue nil # rubocop:disable Style/RescueModifier
76
+ end
77
+ end
78
+
79
+ AllowlistProxy.new(ARGV, port: Integer(ENV.fetch("PORT", "3128"))).run if $PROGRAM_NAME == __FILE__
@@ -12,6 +12,7 @@ module Lemans
12
12
  HOUSEKEEPING_TIMEOUT = 60
13
13
  MAX_OUTPUT_BYTES = 200_000
14
14
  EXEC_SLACK = 30
15
+ PROXY_PORT = 3128
15
16
 
16
17
  attr_reader :container
17
18
 
@@ -24,7 +25,8 @@ module Lemans
24
25
  end
25
26
 
26
27
  def start
27
- build_image! if image.built?
28
+ build_image!(image) if image.built?
29
+ start_proxy!(network.hosts) if network.allowlist?
28
30
  docker!("run", *run_args, timeout: build_timeout)
29
31
  @container = @name
30
32
  self
@@ -37,9 +39,10 @@ module Lemans
37
39
  timeout ||= DEFAULT_TIMEOUT
38
40
  started = now
39
41
  argv = [ "exec" ]
42
+ env = proxy_env.merge(env) if network.allowlist?
40
43
  env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
41
44
  # The in-container timeout is what actually kills the process
42
- argv += [ container, "timeout", timeout.to_i.to_s, "sh", "-c", command ]
45
+ argv += [ container, "timeout", timeout.to_i.to_s, "bash", "-c", command ]
43
46
 
44
47
  exit_code, output = capture("docker", *argv, timeout: timeout + EXEC_SLACK)
45
48
  ExecResult.new(command:, exit_code:, output:, duration: (now - started).round(3))
@@ -59,13 +62,13 @@ module Lemans
59
62
  def switch_network_policy!(policy)
60
63
  assert_policy_supported!(policy)
61
64
 
62
- if policy.none?
63
- connected_networks.each { docker!("network", "disconnect", it, container) }
64
- else
65
- networks = connected_networks
66
- docker!("network", "disconnect", "none", container) if networks.include?("none")
67
- docker!("network", "connect", "bridge", container) unless networks.include?("bridge")
68
- end
65
+ wanted = ("bridge" if policy.public?) || (internal_network if policy.allowlist?)
66
+ networks = connected_networks
67
+ (networks - [ wanted ]).each { docker!("network", "disconnect", it, container) }
68
+
69
+ remove_proxy
70
+ start_proxy!(policy.hosts) if policy.allowlist?
71
+ docker!("network", "connect", wanted, container) if wanted && !networks.include?(wanted)
69
72
 
70
73
  @network = policy
71
74
  end
@@ -81,41 +84,101 @@ module Lemans
81
84
  end
82
85
  rescue StandardError => e
83
86
  warn "lemans: container #{container} may still be running — remove failed: #{e.class}: #{e.message}"
87
+ ensure
88
+ remove_proxy
89
+ remove_internal_network
84
90
  end
85
91
 
86
92
  private
87
93
 
88
94
  def assert_policy_supported!(policy)
89
- return if policy.none? || policy.public?
95
+ return unless policy.allowlist? && policy.ip_targets.any?
90
96
 
91
- raise ConfigError, "docker: #{policy.mode} is not supported (public and none only)"
97
+ raise ConfigError, "docker: an allowlist takes host names only (#{policy.ip_targets.join(", ")})"
92
98
  end
93
99
 
94
100
  # The tag is the content digest, so an existing image is the identical thing
95
- def build_image!
96
- exists, = capture("docker", "image", "inspect", image.name, timeout: HOUSEKEEPING_TIMEOUT)
101
+ def build_image!(spec)
102
+ exists, = capture("docker", "image", "inspect", spec.name, timeout: HOUSEKEEPING_TIMEOUT)
97
103
  return if exists.zero?
98
104
 
99
- docker!("build", "--tag", image.name, image.context_dir.to_s, timeout: build_timeout, on_output: @logger)
105
+ docker!("build", "--tag", spec.name, spec.context_dir.to_s, timeout: build_timeout, on_output: @logger)
106
+ end
107
+
108
+ # The task container sits on an internal network with no route out; the
109
+ # proxy is on it and on bridge, so the hosts it allows are the only way out.
110
+ def start_proxy!(hosts)
111
+ image = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
112
+ build_image!(image)
113
+ create_internal_network!
114
+ docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
115
+ *label_args, image.name, *hosts)
116
+ docker!("network", "connect", "bridge", proxy_name)
117
+ wait_for_proxy!
118
+ end
119
+
120
+ def wait_for_proxy!(attempts: 50)
121
+ attempts.times do
122
+ _, output = capture("docker", "logs", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
123
+ return if output.include?("listening on")
124
+
125
+ sleep 0.2
126
+ end
127
+
128
+ raise InfrastructureError, "docker: the allowlist proxy did not start"
100
129
  end
101
130
 
131
+ def proxy_env
132
+ url = "http://#{proxy_name}:#{PROXY_PORT}"
133
+ no_proxy = "localhost,127.0.0.1"
134
+ { "http_proxy" => url, "https_proxy" => url, "HTTP_PROXY" => url, "HTTPS_PROXY" => url,
135
+ "no_proxy" => no_proxy, "NO_PROXY" => no_proxy }
136
+ end
137
+
138
+ def create_internal_network!
139
+ exists, = capture("docker", "network", "inspect", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
140
+ return if exists.zero?
141
+
142
+ docker!("network", "create", "--internal", *label_args, internal_network)
143
+ end
144
+
145
+ def remove_proxy
146
+ capture("docker", "rm", "--force", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def remove_internal_network
152
+ capture("docker", "network", "rm", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def proxy_name = "#{@name}-proxy"
158
+
159
+ def internal_network = "#{@name}-net"
160
+
102
161
  def run_args
103
162
  args = [ "--detach", "--init", "--name", @name,
104
163
  "--cpus", resources.cpus.to_s, "--memory", "#{resources.memory}m",
105
164
  "--cap-add", "SYS_ADMIN", "--cap-add", "NET_ADMIN", "--security-opt", "apparmor=unconfined",
106
165
  "--entrypoint", "sh" ]
107
166
  args += [ "--network", "none" ] if network.none?
167
+ args += [ "--network", internal_network ] if network.allowlist?
108
168
  env.each { |key, value| args += [ "--env", "#{key}=#{value}" ] }
109
- labels.each { |key, value| args += [ "--label", "#{key}=#{value}" ] }
110
- args + [ image.name, "-c", "tail -f /dev/null" ]
169
+ args + label_args + [ image.name, "-c", "tail -f /dev/null" ]
111
170
  end
112
171
 
172
+ def label_args = labels.flat_map { |key, value| [ "--label", "#{key}=#{value}" ] }
173
+
113
174
  def connected_networks
114
175
  docker!("inspect", "--format", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}", container).split
115
176
  end
116
177
 
117
178
  def remove
118
179
  capture("docker", "rm", "--force", "--volumes", @name, timeout: HOUSEKEEPING_TIMEOUT)
180
+ remove_proxy
181
+ remove_internal_network
119
182
  rescue StandardError
120
183
  nil
121
184
  end