lemans 0.2.2 → 1.0.0.pre.1

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
data/lib/lemans/cli.rb CHANGED
@@ -19,12 +19,14 @@ module Lemans
19
19
 
20
20
  desc "tasks", "List the tasks in a bench"
21
21
  option :bench, default: ".", desc: "Directory holding bench.yml"
22
- option :tag, desc: "Only tasks carrying this tag"
22
+ option :tag, desc: "Only tasks carrying this tag", repeatable: true
23
23
  def tasks
24
- bench = Bench.load(options[:bench])
24
+ config = Config.load_file(options[:bench])
25
+ tasks = filter_tasks(config.tasks, tags: options[:tag])
26
+
25
27
  print_table(
26
28
  [%w[task difficulty tags description]] +
27
- select_tasks(bench).map { [_1.name, _1.difficulty, _1.tags.join(","), _1.description] }
29
+ tasks.map { [it.name, it.difficulty, it.tags.join(","), it.description] }
28
30
  )
29
31
  rescue ConfigError => e
30
32
  raise Thor::Error, "lemans: #{e.message}"
@@ -34,12 +36,12 @@ module Lemans
34
36
  desc "run", "Run tasks and verify them"
35
37
  option :bench, default: ".", desc: "Directory holding bench.yml"
36
38
  option :task, desc: "Run task(s) by name", repeatable: true
37
- option :tag, desc: "Run every task carrying this tag"
38
- option :agent, desc: "Override the agent from bench.yml (miniswen, oracle, nop)"
39
+ option :tag, desc: "Run every task carrying this tag(s)", repeatable: true
40
+ option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop)"
39
41
  option :model, desc: "Override the model(s) from bench.yml", repeatable: true
40
42
  option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
41
43
  option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
42
- option :runs_dir, default: "runs", desc: "Where to write run directories"
44
+ option :runs_dir, default: "./runs", desc: "Where to write run directories"
43
45
  option :backend, default: "daytona", enum: Environments::BACKENDS.keys, desc: "Sandbox backend"
44
46
  option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
45
47
  def run_bench
@@ -47,74 +49,68 @@ module Lemans
47
49
  # front, so every trial prices completions against the same revision.
48
50
  Miniswen.refresh_registry!
49
51
 
50
- bench = Bench.load(options[:bench])
51
- tasks = select_tasks(bench)
52
-
53
- run = Run.new(
54
- bench: bench,
55
- tasks: tasks,
56
- agent_name: options[:agent] || bench.agent.name,
57
- model: options[:model],
58
- backend: options[:backend],
59
- runs_dir: options[:runs_dir],
60
- attempts: Integer(options[:attempts]),
61
- concurrency: Integer(options[:concurrency]),
62
- resume: options[:resume]
63
- )
64
- if run.total.zero?
52
+ config = Config.load_file(options[:bench])
53
+ config.load_options(**options.transform_keys(&:to_sym))
54
+
55
+ tasks = filter_tasks(config.tasks, tags: options[:tag], name: options[:task])
56
+
57
+ store = Stores::FS.new(options[:runs_dir])
58
+
59
+ runner = Runner.new(config, tasks, store:, resume: options[:resume])
60
+
61
+ if runner.resuming? && runner.attempts.empty?
65
62
  say_status :resume, "nothing to run — every task × model already has " \
66
- "#{options[:attempts]} scored attempt(s)", :green
63
+ "#{config.attempts} scored attempt(s)", :green
64
+
65
+ return
67
66
  end
68
67
 
69
- # A tty gets the live board; a pipe gets plain streamed lines.
70
- progress =
68
+ reporter =
71
69
  if interactive?
72
- models = options[:model] || (bench.agent.models.empty? ? [bench.agent.model] : bench.agent.models)
73
- BoardReporter.new(tasks: tasks.map(&:name), models: models,
74
- attempts: Integer(options[:attempts]), total: run.total)
70
+ BoardReporter.new(tasks: tasks.map(&:name), models: config.models,
71
+ attempts: config.attempts, total: runner.attempts.size)
75
72
  else
76
- ProgressReporter.new(shell: shell, task_width: tasks.map { _1.name.length }.max)
73
+ ProgressReporter.new(shell:, tasks: tasks.map(&:name))
77
74
  end
78
- progress.start
79
- summary = run.call { |event, data| progress.record(event, data) }
80
- progress.stop
75
+
76
+ reporter.start
77
+
78
+ summary = runner.run(reporter)
81
79
 
82
80
  say ""
83
81
  say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
84
- print_report Results::Report.load(options[:runs_dir])
85
- exit 130 if summary[:interrupted]
86
- exit 1 if summary[:invalid].positive?
82
+ print_report Report.load(store)
83
+
84
+ exit 130 if summary.status == :interrupted
85
+ exit 1 if summary.status == :invalid
87
86
  rescue ConfigError => e
88
87
  raise Thor::Error, "lemans: #{e.message}"
89
88
  rescue Interrupt
90
89
  say ""
91
90
  exit 130
92
91
  ensure
93
- progress&.stop
92
+ reporter&.stop
94
93
  end
95
94
 
96
95
  desc "clobber", "Delete run results"
97
- option :runs_dir, default: "runs", desc: "Directory holding run directories"
98
- option :task, type: :array, desc: "Only these tasks' runs (space-separated)"
96
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories"
97
+ option :task, desc: "Only these tasks' runs", repeatable: true
99
98
  option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
100
99
  option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
101
100
  option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
102
101
  def clobber
103
- clobber = Clobber.new(
104
- runs_dir: options[:runs_dir],
105
- tasks: options[:task],
106
- ttl_sec: Units.seconds(options[:ttl], field: "--ttl"),
107
- invalid: options[:invalid]
108
- )
102
+ store = Stores::FS.new(options[:runs_dir])
103
+ clobber = Clobber.new(store, tasks: options[:task], ttl: options[:ttl], invalid: options[:invalid])
104
+
109
105
  doomed = clobber.matches
110
106
  return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
111
107
 
112
108
  unless options[:force]
113
- doomed.each { say _1.to_s }
109
+ doomed.each { say it.id }
114
110
  return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
115
111
  end
116
112
 
117
- removed = clobber.call
113
+ removed = clobber.execute!
118
114
  say "deleted #{removed.size} run(s)"
119
115
  rescue ConfigError => e
120
116
  raise Thor::Error, "lemans: #{e.message}"
@@ -122,19 +118,18 @@ module Lemans
122
118
 
123
119
  desc "report", "Summarize run results as a table or CSV"
124
120
  option :runs_dir, default: "runs", desc: "Directory holding run directories"
125
- option :tag, desc: "Only runs whose result carries this tag"
121
+ option :tag, desc: "Only runs whose result carries this tag", repeatable: true
122
+ option :task, desc: "Only these tasks' runs", repeatable: true
126
123
  option :format, default: "table", enum: %w[table csv], desc: "Output format"
127
124
  option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
128
125
  desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
129
126
  option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
130
127
  def report
131
- results = Results::Report.load(options[:runs_dir], tag: options[:tag])
132
- if results.empty?
133
- tagged = options[:tag] ? " tagged #{options[:tag].inspect}" : ""
134
- raise Thor::Error, "lemans: no results#{tagged} under #{options[:runs_dir]}"
135
- end
128
+ store = Stores::FS.new(options[:runs_dir])
129
+ results = Report.load(store, tags: options[:tag], names: options[:task])
130
+ raise Thor::Error, "lemans: no matching results found" if results.empty?
136
131
 
137
- results = Results::Aggregate.new(results, keys: Results::Aggregate.keys(options[:aggregate])) if options[:aggregate]
132
+ results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
138
133
  results.order_by!(options[:sort]) if options[:sort]
139
134
  options[:format] == "csv" ? say(results.to_csv) : print_report(results)
140
135
  rescue ConfigError => e
@@ -143,24 +138,24 @@ module Lemans
143
138
 
144
139
  private
145
140
 
146
- # The one task filter for every command that walks a bench: an empty
147
- # selection is an error, because running or listing nothing is never
148
- # what a named task or tag meant.
149
- def select_tasks(bench)
150
- tasks = bench.tasks
151
- tasks = tasks.select { options[:task].include?(_1.name) } if options[:task]
152
- tasks = tasks.select { _1.tags.include?(options[:tag]) } if options[:tag]
141
+ def filter_tasks(tasks, tags: nil, name: nil)
142
+ tasks = tasks.dup
143
+
144
+ name = Array(name) if name
145
+ tags = Array(tags) if tags
146
+
147
+ tasks.select! { name.include?(it.name) } if name
148
+ tasks.select! { tags.intersect?(it.tags) } if tags
149
+
153
150
  return tasks unless tasks.empty?
154
151
 
155
- wanted = [options[:task] && "task named #{options[:task].inspect}",
156
- options[:tag] && "task tagged #{options[:tag].inspect}"].compact.join(" and no ")
157
- raise Thor::Error, "lemans: no #{wanted.empty? ? "tasks in #{options[:bench]}" : wanted}"
152
+ raise Thor::Error, "lemans: no matching tasks"
158
153
  end
159
154
 
160
155
  def print_report(report)
161
156
  print_table report.to_rows
162
157
  color = report.summary[:invalid].positive? ? :red : nil
163
- report.summary_lines.each { say _1, color }
158
+ report.summary_lines.each { say it, color }
164
159
  end
165
160
 
166
161
  def interactive?
@@ -1,18 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "json"
5
- require "pathname"
6
-
7
3
  module Lemans
8
- # Deletes run directories based on the provided filters.
4
+ # Deletes stored results based on the provided filters.
9
5
  class Clobber
10
- TRIAL_DIR = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
6
+ include Config::Conversion
7
+
8
+ private attr_reader :store, :tasks, :ttl, :invalid
11
9
 
12
- def initialize(runs_dir:, tasks: [], ttl_sec: nil, invalid: false)
13
- @runs_dir = Pathname(runs_dir)
10
+ def initialize(store, tasks: [], ttl: nil, invalid: false)
11
+ @store = store
14
12
  @tasks = Array(tasks)
15
- @ttl_sec = ttl_sec
13
+ @ttl = ttl && seconds!(ttl)
16
14
  @invalid = invalid
17
15
  end
18
16
 
@@ -20,60 +18,24 @@ module Lemans
20
18
  @matches ||= select_matches
21
19
  end
22
20
 
23
- # Deletes everything it can, says what it could not, and returns what it
24
- # actually removed — the caller's "deleted N" must not count survivors.
25
- def call
26
- deleted = matches.select do |entry|
27
- FileUtils.remove_entry(entry.to_s)
28
- true
29
- rescue SystemCallError => e
30
- warn "lemans: could not delete #{entry}: #{e.message}"
31
- false
32
- end
33
- prune_emptied_parents(deleted)
34
- deleted
21
+ def execute!
22
+ matches.select { store.delete(it) }
35
23
  end
36
24
 
37
25
  private
38
26
 
39
- attr_reader :runs_dir, :tasks, :ttl_sec
40
-
41
27
  def select_matches
42
- return [] unless runs_dir.directory?
43
-
44
- runs_dir.glob("**/").map(&:cleanpath).sort.select do |entry|
45
- task = task_name(entry)
46
- next false if task.nil?
28
+ candidates = store.fetch
29
+ candidates += store.unreadable if invalid
47
30
 
48
- (tasks.empty? || tasks.include?(task)) &&
49
- (ttl_sec.nil? || age_sec(entry) > ttl_sec) &&
50
- (!@invalid || invalid?(entry))
31
+ matched = candidates.select do |result|
32
+ (tasks.empty? || tasks.include?(result.task)) &&
33
+ (ttl.nil? || age(result) > ttl) &&
34
+ (!invalid || result.invalid?)
51
35
  end
36
+ matched.sort_by { it.id.to_s }
52
37
  end
53
38
 
54
- def prune_emptied_parents(deleted)
55
- root = runs_dir.cleanpath
56
- deleted.each do |entry|
57
- dir = entry.parent
58
- while dir != root && dir.children.empty?
59
- dir.rmdir
60
- dir = dir.parent
61
- end
62
- rescue SystemCallError
63
- next
64
- end
65
- end
66
-
67
- def task_name(entry) = TRIAL_DIR.match(entry.basename.to_s)&.[](:task)
68
-
69
- def age_sec(entry) = Time.now - entry.mtime
70
-
71
- # A trial that measured nothing; an unreadable result counts — it will
72
- # never be read as anything else.
73
- def invalid?(entry)
74
- JSON.parse(entry.join("result.json").read).dig("outcome", "scored") != true
75
- rescue JSON::ParserError, SystemCallError, IOError
76
- true
77
- end
39
+ def age(result) = result.finished_at ? Time.now.utc - result.finished_at : Float::INFINITY
78
40
  end
79
41
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ class Agent # :nodoc:
6
+ class << self
7
+ include Conversion
8
+
9
+ def from_config(data)
10
+ return if data.nil?
11
+
12
+ name = data.fetch("name")
13
+ model = data.fetch("model")
14
+
15
+ conf = new(name, model)
16
+
17
+ conf.step_limit = integer!(data["step_limit"]) if data["step_limit"]
18
+ conf.cost_limit = float!(data["cost_limit"]) if data["cost_limit"]
19
+ conf.timeout = seconds!(data["timeout"]) if data["timeout"]
20
+ conf.exec_timeout = seconds!(data["exec_timeout"]) if data["exec_timeout"]
21
+
22
+ if (network_data = data.dig("environment", "network"))
23
+ conf.environment = Environment.new(network: NetworkPolicy.from_config(network_data))
24
+ end
25
+
26
+ conf
27
+ rescue KeyError => e
28
+ raise ConfigError, "agent.#{e.key} must be provided"
29
+ end
30
+ end
31
+
32
+ attr_accessor :name, :models, :timeout,
33
+ :step_limit, :cost_limit, :exec_timeout,
34
+ :environment
35
+
36
+ def model = models.first
37
+
38
+ Environment = Struct.new(:network, keyword_init: true)
39
+
40
+ def initialize(name, model)
41
+ @name = name
42
+ @models = Array(model)
43
+ @step_limit = 100
44
+ @cost_limit = nil
45
+ @exec_timeout = 300
46
+ @timeout = 30 * 60
47
+ @environment = Environment.new(network: NetworkPolicy.new)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ class Config
7
+ # Helper methods to parse/convert config-provided values
8
+ module Conversion
9
+ DURATION = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\z/
10
+ DURATION_FACTORS = { "ms" => 0.001, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
11
+
12
+ SIZE = /\A(\d+(?:\.\d+)?)\s*(MB|GB|TB)?\z/i
13
+ SIZE_FACTORS = { "mb" => 1, "gb" => 1024, "tb" => 1024 * 1024 }.freeze
14
+
15
+ def integer!(value)
16
+ Integer(value)
17
+ rescue ArgumentError, TypeError
18
+ raise ConfigError, "cannot read #{value.inspect} as an integer"
19
+ end
20
+
21
+ def float!(value)
22
+ Float(value)
23
+ rescue ArgumentError, TypeError
24
+ raise ConfigError, "cannot read #{value.inspect} as a number"
25
+ end
26
+
27
+ def seconds!(value)
28
+ if value.is_a?(Numeric)
29
+ raise ConfigError, "duration must be non-negative, got: #{value}" if value.negative?
30
+
31
+ return value
32
+ end
33
+
34
+ match = DURATION.match(value.to_s.strip)
35
+
36
+ raise ConfigError, "cannot read #{value.inspect} as a duration (try 30m, 300s, 1h)" unless match
37
+
38
+ match[1].to_f * DURATION_FACTORS.fetch(match[2] || "s")
39
+ end
40
+
41
+ def megabytes!(value)
42
+ if value.is_a?(Numeric)
43
+ raise ConfigError, "size must be non-negative, got: #{value}" if value.negative?
44
+
45
+ return value.round
46
+ end
47
+
48
+ match = SIZE.match(value.to_s.strip)
49
+
50
+ raise ConfigError, "cannot read #{value.inspect} as a size (try 2GB, 512MB)" unless match
51
+
52
+ (match[1].to_f * SIZE_FACTORS.fetch((match[2] || "mb").downcase)).round
53
+ end
54
+
55
+ def absolute_path!(value)
56
+ raise ConfigError, "#{value.inspect} is not an absolute path" unless value.start_with?("/")
57
+
58
+ value
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ class Environment # :nodoc:
6
+ Resources = Struct.new(:cpus, :memory, :storage, keyword_init: true)
7
+
8
+ class << self
9
+ include Conversion
10
+
11
+ def from_config(data)
12
+ return if data.nil?
13
+
14
+ conf = new
15
+ conf.image = data["image"] if data["image"]
16
+ conf.workdir = absolute_path!(data["workdir"]) if data["workdir"]
17
+ conf.build_timeout = seconds!(data["build_timeout"]) if data["build_timeout"]
18
+ conf.network = NetworkPolicy.from_config(data["network"]) if data["network"]
19
+
20
+ conf.resources.cpus = integer!(data.dig("resources", "cpus")) if data.dig("resources", "cpus")
21
+ conf.resources.memory = megabytes!(data.dig("resources", "memory")) if data.dig("resources", "memory")
22
+ conf.resources.storage = megabytes!(data.dig("resources", "storage")) if data.dig("resources", "storage")
23
+
24
+ conf
25
+ end
26
+ end
27
+
28
+ attr_accessor :image, :workdir, :resources, :build_timeout, :network
29
+
30
+ def initialize
31
+ @image = nil
32
+ @workdir = "/app"
33
+ @resources = Resources.new(cpus: 2, memory: 2048, storage: 5120)
34
+ @build_timeout = 10 * 60
35
+ @network = NetworkPolicy.new
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+
6
+ module Lemans
7
+ class Config
8
+ # An image, either already published or built from a task's Dockerfile.
9
+ # Built images are named by content digest, so reuse is only ever of the identical thing.
10
+ class ImageSpec
11
+ attr_reader :reference, :dockerfile_path, :slug, :digest
12
+
13
+ def self.registry(reference) = new(reference:)
14
+
15
+ def self.dockerfile(path, slug:)
16
+ path = Pathname(path)
17
+ raise ConfigError, "no Dockerfile at #{path}" unless path.file?
18
+
19
+ new(dockerfile_path: path, slug:)
20
+ end
21
+
22
+ def initialize(reference: nil, dockerfile_path: nil, slug: nil)
23
+ @reference = reference
24
+ @dockerfile_path = dockerfile_path
25
+ @slug = slug
26
+
27
+ # Hashing the reference gives backends one answer to "is this the same image" either way.
28
+ @digest = built? ? TreeDigest.call(context_dir) : Digest::SHA256.hexdigest(reference.to_s)
29
+ end
30
+
31
+ def built? = !dockerfile_path.nil?
32
+
33
+ def context_dir = dockerfile_path&.dirname
34
+
35
+ def name
36
+ built? ? "lemans-#{digest[0, 32]}" : reference
37
+ end
38
+
39
+ def to_s = name
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Lemans
6
+ class Config
7
+ class NetworkPolicy # :nodoc:
8
+ MODES = %w[none allowlist public].freeze
9
+
10
+ MODES.each do |name|
11
+ define_method(:"#{name}?") { mode == name.to_s }
12
+ end
13
+
14
+ class << self
15
+ def from_config(data)
16
+ return if data.nil?
17
+
18
+ mode = (data["mode"] || "public").to_s
19
+ raise ConfigError, "#{mode.inspect} is not a network mode (#{MODES.join(", ")})" unless MODES.include?(mode)
20
+
21
+ hosts = data["hosts"]
22
+ raise ConfigError, "network.hosts is only meaningful with mode: allowlist" if mode != "allowlist" && hosts
23
+ raise ConfigError, "network.hosts must be provided with mode: allowlist" if mode == "allowlist" && Array(hosts).empty?
24
+
25
+ new(mode, hosts)
26
+ end
27
+ end
28
+
29
+ attr_reader :mode, :hosts
30
+
31
+ def initialize(mode = "public", hosts = nil)
32
+ @mode = mode.to_s
33
+ @hosts = hosts
34
+ end
35
+
36
+ # Backends allowlist domains and IP ranges through separate APIs.
37
+ def domains = partitioned_hosts.last
38
+
39
+ def ip_targets = partitioned_hosts.first
40
+
41
+ private
42
+
43
+ def partitioned_hosts
44
+ @partitioned_hosts ||= Array(hosts).partition { ip_target?(it) }
45
+ end
46
+
47
+ def ip_target?(entry)
48
+ IPAddr.new(entry)
49
+ true
50
+ rescue IPAddr::Error
51
+ false
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Lemans
6
+ class Config
7
+ # Which revision of a bench produced a score.
8
+ # Obtain either from git or from ENV["COMMIT_SHA"] (lemans-remote convention)
9
+ class Revision
10
+ class << self
11
+ def detect(dir)
12
+ commit, dirty = ENV["COMMIT_SHA"]&.split("~")
13
+ return new(commit, dirty == "!") if commit
14
+
15
+ commit = git("rev-parse", "HEAD", dir:)
16
+ return new if commit.nil?
17
+
18
+ status = git("status", "--porcelain", dir:)
19
+ new(commit, status.nil? ? nil : !status.empty?)
20
+ end
21
+
22
+ private
23
+
24
+ def git(*, dir:)
25
+ output, status = Open3.capture2e("git", "-C", dir.to_s, *)
26
+ status.success? ? output.strip : nil
27
+ rescue SystemCallError
28
+ nil
29
+ end
30
+ end
31
+
32
+ attr_reader :commit, :dirty
33
+
34
+ def initialize(commit = nil, dirty = nil)
35
+ @commit = commit
36
+ @dirty = dirty
37
+ end
38
+
39
+ def as_json(**) = { commit:, dirty: }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ # One phase's preparation: the files uploaded into the sandbox and the
6
+ # commands run against them. A bare list is shorthand for commands only.
7
+ class Setup
8
+ class << self
9
+ def from_config(data, root:)
10
+ return if data.nil?
11
+
12
+ conf = new
13
+ case data
14
+ when Hash
15
+ conf.commands = Array(data["commands"])
16
+ conf.files = Array(data["files"]).map { file!(it, root:) }
17
+ else
18
+ conf.commands = Array(data)
19
+ end
20
+
21
+ conf
22
+ end
23
+
24
+ private
25
+
26
+ # Files are confined to the directory that declared them and must
27
+ # exist at load time.
28
+ def file!(path, root:)
29
+ raise ConfigError, "setup file #{path.inspect} must be relative to #{root}" if path.start_with?("/")
30
+
31
+ relative = Pathname(path).cleanpath
32
+ raise ConfigError, "setup file #{path.inspect} must name a file inside #{root}" if relative.each_filename.include?("..") || relative.to_s == "."
33
+ raise ConfigError, "setup file #{path} is not a file" unless root.join(relative).file?
34
+
35
+ [root.join(relative), relative.to_s]
36
+ end
37
+ end
38
+
39
+ # Files are [local absolute, remote relative] pairs; commands run in order.
40
+ attr_accessor :files, :commands
41
+
42
+ def initialize
43
+ @files = []
44
+ @commands = []
45
+ end
46
+
47
+ def merge(other)
48
+ dup.tap do |merged|
49
+ merged.files = files + other.files
50
+ merged.commands = commands + other.commands
51
+ end
52
+ end
53
+
54
+ def empty? = files.empty? && commands.empty?
55
+ end
56
+ end
57
+ end