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.
- checksums.yaml +4 -4
- data/README.md +30 -10
- data/exe/lemans-remote +161 -46
- data/lib/lemans/agent.rb +39 -0
- data/lib/lemans/agents/miniswen.rb +28 -25
- data/lib/lemans/agents/miniswen_installed.rb +13 -9
- data/lib/lemans/agents/nop.rb +3 -3
- data/lib/lemans/agents/oracle.rb +8 -8
- data/lib/lemans/cli/board_reporter.rb +16 -16
- data/lib/lemans/cli/progress_reporter.rb +19 -19
- data/lib/lemans/cli/report/aggregate.rb +117 -0
- data/lib/lemans/cli/report.rb +164 -0
- data/lib/lemans/cli.rb +58 -63
- data/lib/lemans/clobber.rb +17 -55
- data/lib/lemans/config/agent.rb +51 -0
- data/lib/lemans/config/conversion.rb +62 -0
- data/lib/lemans/config/environment.rb +39 -0
- data/lib/lemans/config/image_spec.rb +42 -0
- data/lib/lemans/config/network_policy.rb +55 -0
- data/lib/lemans/config/revision.rb +42 -0
- data/lib/lemans/config/setup.rb +57 -0
- data/lib/lemans/config/tree_digest.rb +26 -0
- data/lib/lemans/config/verifier.rb +72 -0
- data/lib/lemans/config.rb +107 -0
- data/lib/lemans/environment.rb +54 -0
- data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
- data/lib/lemans/environments/daytona.rb +21 -35
- data/lib/lemans/result.rb +270 -0
- data/lib/lemans/runner/executor.rb +64 -0
- data/lib/lemans/runner/task.rb +62 -0
- data/lib/lemans/runner.rb +82 -0
- data/lib/lemans/store.rb +44 -0
- data/lib/lemans/stores/fs.rb +122 -0
- data/lib/lemans/task_definition.rb +194 -0
- data/lib/lemans/trial/patch.rb +76 -0
- data/lib/lemans/trial/setup.rb +66 -0
- data/lib/lemans/trial/snapshot.rb +57 -0
- data/lib/lemans/trial/verifier.rb +190 -0
- data/lib/lemans/trial.rb +113 -147
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +3 -2
- data/lib/miniswen/trajectory.rb +2 -0
- metadata +58 -25
- data/lib/lemans/agents/base.rb +0 -30
- data/lib/lemans/bench.rb +0 -280
- data/lib/lemans/environments/base.rb +0 -55
- data/lib/lemans/network_policy.rb +0 -66
- data/lib/lemans/patch.rb +0 -70
- data/lib/lemans/restore_paths.rb +0 -21
- data/lib/lemans/results/aggregate.rb +0 -114
- data/lib/lemans/results/cost_source.rb +0 -13
- data/lib/lemans/results/outcome.rb +0 -36
- data/lib/lemans/results/report.rb +0 -149
- data/lib/lemans/results/sorting.rb +0 -24
- data/lib/lemans/results/tally.rb +0 -19
- data/lib/lemans/results/usage.rb +0 -24
- data/lib/lemans/run.rb +0 -152
- data/lib/lemans/setup.rb +0 -59
- data/lib/lemans/setup_files.rb +0 -36
- data/lib/lemans/snapshot.rb +0 -55
- data/lib/lemans/task.rb +0 -207
- data/lib/lemans/tree_digest.rb +0 -24
- data/lib/lemans/units.rb +0 -44
- data/lib/lemans/verifier.rb +0 -199
- /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
"#{
|
|
63
|
+
"#{config.attempts} scored attempt(s)", :green
|
|
64
|
+
|
|
65
|
+
return
|
|
67
66
|
end
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
progress =
|
|
68
|
+
reporter =
|
|
71
69
|
if interactive?
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
73
|
+
ProgressReporter.new(shell:, tasks: tasks.map(&:name))
|
|
77
74
|
end
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
exit
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
tasks
|
|
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
|
-
|
|
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
|
|
158
|
+
report.summary_lines.each { say it, color }
|
|
164
159
|
end
|
|
165
160
|
|
|
166
161
|
def interactive?
|
data/lib/lemans/clobber.rb
CHANGED
|
@@ -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
|
|
4
|
+
# Deletes stored results based on the provided filters.
|
|
9
5
|
class Clobber
|
|
10
|
-
|
|
6
|
+
include Config::Conversion
|
|
7
|
+
|
|
8
|
+
private attr_reader :store, :tasks, :ttl, :invalid
|
|
11
9
|
|
|
12
|
-
def initialize(
|
|
13
|
-
@
|
|
10
|
+
def initialize(store, tasks: [], ttl: nil, invalid: false)
|
|
11
|
+
@store = store
|
|
14
12
|
@tasks = Array(tasks)
|
|
15
|
-
@
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
(
|
|
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
|
|
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
|