lemans 1.3.5 → 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.
@@ -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
data/lib/lemans/result.rb CHANGED
@@ -136,6 +136,12 @@ module Lemans
136
136
  def as_json(**) = to_h
137
137
  end
138
138
 
139
+ Restart = Data.define(:trial, :step, :mode) do
140
+ def initialize(trial:, step:, mode: nil) = super
141
+
142
+ def as_json(**) = to_h.compact
143
+ end
144
+
139
145
  Step = Data.define(:outcome, :usage, :duration) do
140
146
  def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
141
147
 
@@ -150,7 +156,7 @@ module Lemans
150
156
  attr_reader :id, :task, :agent, :model, :index,
151
157
  :profile_digest, :task_digest, :revision
152
158
 
153
- attr_accessor :tags, :metadata
159
+ attr_accessor :tags, :metadata, :restarted_from
154
160
 
155
161
  attr_reader :phases, :steps
156
162
 
@@ -171,6 +177,7 @@ module Lemans
171
177
  @metadata = {}
172
178
  @phases = []
173
179
  @steps = nil
180
+ @restarted_from = nil
174
181
 
175
182
  @id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
176
183
  @outcome = Outcome.new(:pending)
@@ -239,12 +246,55 @@ module Lemans
239
246
  self
240
247
  end
241
248
 
249
+ # A step is settled once the next one started: it passed its gate and left a savepoint.
250
+ def settled_steps
251
+ names = phases.map(&:name)
252
+ (1..steps.to_a.size).count { names.include?(:"agent.#{it + 1}") }
253
+ end
254
+
255
+ # The step whose verification ran last, nil when none did
256
+ def verified_step
257
+ name = phases.reverse.find { it.name.to_s.match?(/\Averifier(\.\d+)?\z/) }&.name or return nil
258
+ name.to_s[/\.(\d+)\z/, 1]&.to_i || steps&.size || 1
259
+ end
260
+
261
+ # Carries over the steps before `step`; a reverification carries `step` itself too.
262
+ def restart!(source, step:, mode: nil)
263
+ @restarted_from = Restart.new(trial: source.id, step:, mode: mode&.to_s)
264
+ carried = mode == :reverify ? step : step - 1
265
+
266
+ if source.steps
267
+ source.steps.first(carried).each { step_completed!(it.outcome, it.usage, duration: it.duration) }
268
+ elsif carried.positive?
269
+ # A single-step run graded before keeps its agent outcome; a failed grading lost it.
270
+ completed!(source.scored? ? source.outcome : Outcome.new(:completed), source.usage)
271
+ end
272
+ self
273
+ end
274
+
275
+ # Splices in the named phases of the source run, shifted so that its
276
+ # `through` phase ends now, and moves this run's environment setup back in
277
+ # front of them: the timeline reads as one run.
278
+ def adopt_phases!(source, names, through: names.last, now: Time.now.utc)
279
+ delta = now - source.phases.find { it.name == through }.finished_at
280
+
281
+ setup = phases.first
282
+ shift = source.phases.find { it.name == setup.name }.finished_at + delta - setup.finished_at
283
+ phases[0] = Phase.new(setup.name, started_at: setup.started_at + shift, finished_at: setup.finished_at + shift)
284
+
285
+ source.phases.select { names.include?(it.name) }.each do |phase|
286
+ phases << Phase.new(phase.name, started_at: phase.started_at + delta, finished_at: phase.finished_at + delta)
287
+ end
288
+ self
289
+ end
290
+
242
291
  private def aggregate_usage = steps.filter_map(&:usage).reduce(:+)
243
292
 
244
293
  def as_json(**)
245
294
  {
246
295
  trial: id, task:, agent:, model:, index:,
247
296
  profile_digest:, task_digest:, revision: revision&.as_json,
297
+ restarted_from: restarted_from&.as_json,
248
298
  lemans_version: VERSION,
249
299
  tags:, metadata:, phases: phases.map(&:as_json),
250
300
  steps: steps&.map(&:as_json),
@@ -264,6 +314,7 @@ module Lemans
264
314
  )
265
315
  result.tags = data[:tags] || []
266
316
  result.metadata = data[:metadata] || {}
317
+ result.restarted_from = Restart.new(**data[:restarted_from]) if data[:restarted_from]
267
318
  phases_from(data).each { result.phases << it }
268
319
 
269
320
  # Steps first: the stored outcome/usage below override the aggregates.
@@ -20,18 +20,22 @@ module Lemans
20
20
  def_delegators :definition, :name, :config
21
21
  def_delegators :result, :id
22
22
 
23
- private attr_reader :definition, :store, :reporter
23
+ private attr_reader :definition, :store, :reporter, :restart_from, :restart_mode
24
24
 
25
- def initialize(model, task_definition, index: 0, store: nil, reporter: nil)
25
+ def initialize(model, task_definition, index: 0, store: nil, reporter: nil, restart_from: nil, restart_mode: nil)
26
26
  @model = model
27
27
  @definition = task_definition
28
28
  @index = index
29
29
  @store = store
30
30
  @reporter = reporter
31
+ @restart_from = restart_from
32
+ @restart_mode = restart_mode
31
33
  @status = :pending
32
34
 
33
- # prepare the result object: it's used by the actual execution down the stack
34
- @result = Result.from_task(definition, index:, model: model || config.models.first)
35
+ # prepare the result object: it's used by the actual execution down the stack;
36
+ # a restart keeps the agent of the run it restarts
37
+ @result = Result.from_task(definition, index:, model: model || config.models.first,
38
+ **({ agent: restart_from.agent } if restart_from))
35
39
  end
36
40
 
37
41
  def with_reporter(reporter)
@@ -55,7 +59,7 @@ module Lemans
55
59
  private
56
60
 
57
61
  def execute!
58
- Trial.new(definition, model, store:, result:).run
62
+ Trial.new(definition, model, store:, result:, agent: restart_from&.agent, restart_from:, restart_mode:).run
59
63
  end
60
64
  end
61
65
  end
data/lib/lemans/runner.rb CHANGED
@@ -20,20 +20,26 @@ module Lemans
20
20
 
21
21
  attr_reader :config, :tasks, :store, :reporter
22
22
 
23
- private attr_reader :resuming, :executor
23
+ private attr_reader :resuming, :restarts, :restart_mode, :allow_scored, :executor
24
24
 
25
- def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false)
25
+ def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false,
26
+ restarts: [], restart_mode: nil, allow_scored: false)
26
27
  @config = config
27
28
  @tasks = tasks
28
29
  @store = store
29
30
  @reporter = reporter
30
31
  @executor = executor || Executor.new(config.concurrency)
31
32
  @resuming = resume
33
+ @restarts = restarts
34
+ @restart_mode = restart_mode
35
+ @allow_scored = allow_scored
32
36
  end
33
37
 
34
38
  def resuming? = @resuming
35
39
 
36
40
  def attempts
41
+ return @attempts ||= restart_attempts if restarts.any?
42
+
37
43
  @attempts ||= config.agent.models.flat_map do |model|
38
44
  @tasks.flat_map do |task|
39
45
  completed = resuming? ? completed_attempts(task, model) : 0
@@ -64,6 +70,43 @@ module Lemans
64
70
 
65
71
  private
66
72
 
73
+ # Every run is checked before any starts: one refused run stops the batch.
74
+ def restart_attempts
75
+ refusals = []
76
+ attempts = restarts.filter_map do |restart|
77
+ restart_attempt(restart)
78
+ rescue ConfigError => e
79
+ refusals << e.message
80
+ nil
81
+ end
82
+ raise ConfigError, refusals.join("\n") if refusals.any?
83
+
84
+ attempts
85
+ end
86
+
87
+ # A run named for a restart is restarted on purpose: a changed task or
88
+ # bench is the operator's call, and a reverification may grade a scored run.
89
+ def restart_attempt(restart)
90
+ refuse = ->(reason) { raise ConfigError, "cannot restart #{restart.id}: #{reason}" }
91
+ task = tasks.find { it.name == restart.task }
92
+ refuse.("task #{restart.task} is not in the bench") unless task
93
+ refuse.("it is already scored (--allow-scored to restart it anyway)") if restart.scored? && restart_mode != :reverify && !allow_scored
94
+
95
+ case restart_mode
96
+ when :reverify
97
+ refuse.("it never reached a verification") unless restart.verified_step
98
+ when :recover
99
+ refuse.("#{restart.agent} cannot recover a session") unless Agents.lookup(restart.agent).recoverable?
100
+
101
+ history = task.multistep? ? "agent.result.#{restart.settled_steps + 1}.json" : "agent.result.json"
102
+ refuse.("it has no #{history} to recover from") unless store&.read_artifact(restart, history)
103
+ else
104
+ refuse.("no step was settled before it failed") if restart.settled_steps.zero?
105
+ end
106
+
107
+ Task.new(restart.model, task, store:, index: restart.index || 1, restart_from: restart, restart_mode:)
108
+ end
109
+
67
110
  def completed_attempts(task, model)
68
111
  completed_runs.count do |run|
69
112
  run.task == task.name &&
data/lib/lemans/store.rb CHANGED
@@ -46,5 +46,11 @@ module Lemans
46
46
  def read_artifact(result, path)
47
47
  raise NotImplementedError
48
48
  end
49
+
50
+ # Returns the sorted paths of the artifacts the result stored (the result
51
+ # record is not one of them)
52
+ def artifacts(result)
53
+ raise NotImplementedError
54
+ end
49
55
  end
50
56
  end
@@ -52,8 +52,8 @@ module Lemans
52
52
  end
53
53
 
54
54
  def delete(result)
55
- dir = root.glob("**/#{result.id}").find(&:directory?)
56
- return unless dir
55
+ dir = result_dir(result)
56
+ return unless dir.directory?
57
57
 
58
58
  FileUtils.remove_entry(dir.to_s)
59
59
  prune_empty_parents(dir.parent)
@@ -90,6 +90,13 @@ module Lemans
90
90
  file.read if file.file?
91
91
  end
92
92
 
93
+ def artifacts(result)
94
+ dir = result_dir(result)
95
+ return [] unless dir.directory?
96
+
97
+ (dir.glob("**/*").select(&:file?).map { it.relative_path_from(dir).to_s } - [ FILENAME ]).sort
98
+ end
99
+
93
100
  private
94
101
 
95
102
  def filtered(text) = filterer ? filterer.filter(text) : text
@@ -132,9 +139,14 @@ module Lemans
132
139
  tmp&.delete if tmp&.exist?
133
140
  end
134
141
 
135
- # result.json is stored at <root>/<model-short>/<result-id>
142
+ # result.json is stored at <root>/<model-short>/<result-id>; a tree
143
+ # arranged by hand (runs grouped under batch directories) is searched
144
+ # for the id instead.
136
145
  def result_dir(result)
137
- root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
146
+ canonical = root.join((result.model || result.agent).to_s.split("/").last.to_s.tr("#", "-"), result.id)
147
+ return canonical if canonical.directory?
148
+
149
+ root.glob("**/#{result.id}").find(&:directory?) || canonical
138
150
  end
139
151
  end
140
152
  end
@@ -61,6 +61,15 @@ module Lemans
61
61
  raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
62
62
  end
63
63
 
64
+ # Brings a fresh tree to where an earlier run left it: the settled steps'
65
+ # patches applied in order and marked as the savepoint, then the work of
66
+ # the step that run stopped in, on top of it, so collect! sees it as this step's.
67
+ def replay!(settled, unsettled = nil)
68
+ settled.each { apply!(it) }
69
+ savepoint!
70
+ apply!(unsettled) if unsettled
71
+ end
72
+
64
73
  # Puts the workdir back to the savepoint exactly: the verifier restored
65
74
  # the graded surfaces from the baseline and may have littered the tree,
66
75
  # and the next step's agent must find neither.
@@ -75,6 +84,18 @@ module Lemans
75
84
 
76
85
  private
77
86
 
87
+ def apply!(contents)
88
+ return if contents.empty?
89
+
90
+ Tempfile.create(%w[agent .patch]) do |file|
91
+ file.write(contents)
92
+ file.flush
93
+ environment.upload(file.path, REMOTE_PATCH)
94
+ end
95
+
96
+ environment.exec!("#{git} apply --binary --whitespace=nowarn #{REMOTE_PATCH} && rm -f #{REMOTE_PATCH}", timeout:)
97
+ end
98
+
78
99
  def save_diff(result, store, from, to, destination)
79
100
  diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
80
101
  return unless diffed.success?
@@ -52,6 +52,7 @@ module Lemans
52
52
  workdir = Shellwords.escape(task.environment.workdir)
53
53
  environment.exec!(
54
54
  "cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
55
+ "find . -path ./.git -prune -o -exec touch -h {} + && " \
55
56
  "rm -rf .git && git init -q && git add -A && " \
56
57
  "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
57
58
  timeout: