lemans 0.0.0.pre → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +669 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +160 -7
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
# Deletes run directories based on the provided filters.
|
|
9
|
+
class Clobber
|
|
10
|
+
TRIAL_DIR = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
|
|
11
|
+
|
|
12
|
+
def initialize(runs_dir:, tasks: [], ttl_sec: nil, invalid: false)
|
|
13
|
+
@runs_dir = Pathname(runs_dir)
|
|
14
|
+
@tasks = Array(tasks)
|
|
15
|
+
@ttl_sec = ttl_sec
|
|
16
|
+
@invalid = invalid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matches
|
|
20
|
+
@matches ||= select_matches
|
|
21
|
+
end
|
|
22
|
+
|
|
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
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :runs_dir, :tasks, :ttl_sec
|
|
40
|
+
|
|
41
|
+
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?
|
|
47
|
+
|
|
48
|
+
(tasks.empty? || tasks.include?(task)) &&
|
|
49
|
+
(ttl_sec.nil? || age_sec(entry) > ttl_sec) &&
|
|
50
|
+
(!@invalid || invalid?(entry))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
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
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Environments
|
|
5
|
+
# What every backend must do, and nothing more: a narrow contract is what
|
|
6
|
+
# makes a second backend a day of work instead of a subsystem.
|
|
7
|
+
class Base
|
|
8
|
+
# Backends interleave a command's streams before we ever see them, so one
|
|
9
|
+
# output field is the honest shape.
|
|
10
|
+
ExecResult = Data.define(:command, :exit_code, :output, :duration_sec) do
|
|
11
|
+
def success? = exit_code.zero?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :image, :resources, :network, :env, :labels, :build_timeout_sec
|
|
15
|
+
|
|
16
|
+
# `labels` is backend-agnostic trial metadata (task, trial id, phase);
|
|
17
|
+
# every backend receives it even if it has nowhere to put it.
|
|
18
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout_sec: nil)
|
|
19
|
+
@image = image
|
|
20
|
+
@resources = resources
|
|
21
|
+
@network = network
|
|
22
|
+
@env = env
|
|
23
|
+
@labels = labels
|
|
24
|
+
@build_timeout_sec = build_timeout_sec
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build the image and bring the sandbox up under the network policy it was
|
|
28
|
+
# constructed with; a backend that cannot honour the policy must raise.
|
|
29
|
+
def start = raise(NotImplementedError)
|
|
30
|
+
|
|
31
|
+
DEFAULT_TIMEOUT = 60
|
|
32
|
+
|
|
33
|
+
def exec(command, timeout: nil, env: {}) = raise(NotImplementedError)
|
|
34
|
+
|
|
35
|
+
def upload(local_path, remote_path) = raise(NotImplementedError)
|
|
36
|
+
|
|
37
|
+
def download(remote_path, local_path) = raise(NotImplementedError)
|
|
38
|
+
|
|
39
|
+
# Phases change what the sandbox may reach: setup pulls packages, the agent
|
|
40
|
+
# reaches the model API and nothing else, the verifier reaches nothing.
|
|
41
|
+
def network_policy=(policy)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop = raise(NotImplementedError)
|
|
46
|
+
|
|
47
|
+
def exec!(command, **)
|
|
48
|
+
result = exec(command, **)
|
|
49
|
+
return result if result.success?
|
|
50
|
+
|
|
51
|
+
raise InfrastructureError, "#{command} exited #{result.exit_code}: #{result.output.to_s[0, 2000]}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "daytona"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module Environments
|
|
7
|
+
class Daytona
|
|
8
|
+
# Another try for calls whose repeat is free. Only reads qualify: a
|
|
9
|
+
# mutation may have landed server-side before its failure surfaced.
|
|
10
|
+
module Retries
|
|
11
|
+
# The snapshot service leaks the generated client's own error classes
|
|
12
|
+
# instead of wrapping them, so both dialects have to be caught.
|
|
13
|
+
SDK_ERRORS = [::Daytona::Sdk::Error, *::Daytona::Sdk::API_ERROR_CLASSES].freeze
|
|
14
|
+
|
|
15
|
+
READ_ATTEMPTS = 3
|
|
16
|
+
RETRY_DELAY_SEC = 2
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_read_retries
|
|
21
|
+
attempts = 0
|
|
22
|
+
begin
|
|
23
|
+
yield
|
|
24
|
+
rescue *SDK_ERRORS => e
|
|
25
|
+
attempts += 1
|
|
26
|
+
raise if attempts >= READ_ATTEMPTS || !retryable?(e)
|
|
27
|
+
|
|
28
|
+
sleep RETRY_DELAY_SEC
|
|
29
|
+
retry
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Transport failures surface as status 0 (libcurl stamps refused/reset/
|
|
34
|
+
# DNS with code 0) or none, and throttling and server errors heal on
|
|
35
|
+
# their own; any other 4xx would fail the same way again.
|
|
36
|
+
def retryable?(error)
|
|
37
|
+
status = status_code(error)
|
|
38
|
+
status.nil? || status.zero? || status == 429 || status >= 500
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def status_code(error)
|
|
42
|
+
return error.status_code if error.is_a?(::Daytona::Sdk::Error)
|
|
43
|
+
|
|
44
|
+
::Daytona::Sdk.api_error_details(error)[:status_code]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "daytona"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
module Environments
|
|
8
|
+
class Daytona
|
|
9
|
+
# Repairs the Daytona SDK needs to be usable from a harness. Upstream ask: per-request
|
|
10
|
+
# timeouts like the Python SDK's — tracked in tmp/upstream-daytona-sdk-timeouts.md.
|
|
11
|
+
module SdkTweaks
|
|
12
|
+
GENERATED_CLIENTS = [
|
|
13
|
+
::DaytonaApiClient, ::DaytonaToolboxApiClient, ::DaytonaAnalyticsApiClient
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Must clear the longest request: an exec long-polling server-side for
|
|
17
|
+
# SHORT_COMMAND_SEC. File transfers do not ride this cap — they go
|
|
18
|
+
# through the SDK's streaming API with their own TRANSFER_TIMEOUT.
|
|
19
|
+
HTTP_TIMEOUT_SEC = Shell::SHORT_COMMAND_SEC + 30
|
|
20
|
+
|
|
21
|
+
# The clients default to timeout=0, libcurl's "never time out"; a dropped connection then
|
|
22
|
+
# parks a thread no Thread#kill reclaims. Only that 0 is replaced; explicit config wins.
|
|
23
|
+
module Deadline
|
|
24
|
+
def timeout
|
|
25
|
+
value = super
|
|
26
|
+
value&.zero? ? HTTP_TIMEOUT_SEC : value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `.configure` is broken — it sets a default config the SDK never reads — and `Sdk.logger`
|
|
31
|
+
# memoizes with no writer, so both are silenced by hand.
|
|
32
|
+
module Quiet
|
|
33
|
+
NULL_LOGGER = Logger.new(IO::NULL)
|
|
34
|
+
|
|
35
|
+
def logger = NULL_LOGGER
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.apply!
|
|
39
|
+
GENERATED_CLIENTS.each { _1::Configuration.prepend(Deadline) }
|
|
40
|
+
return if ENV["DEBUG_DAYTONA"] == "1"
|
|
41
|
+
|
|
42
|
+
GENERATED_CLIENTS.each { _1::Configuration.prepend(Quiet) }
|
|
43
|
+
# The same prepend seam as everything else: poking the @logger ivar
|
|
44
|
+
# would become a silent no-op if the SDK ever renamed it.
|
|
45
|
+
::Daytona::Sdk.singleton_class.prepend(Quiet)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "daytona"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
module Environments
|
|
9
|
+
class Daytona
|
|
10
|
+
# Runs one sandbox's commands. Long commands run detached and are polled — a direct call
|
|
11
|
+
# would die at the HTTP deadline — and no async exit code exists, so the command writes its own.
|
|
12
|
+
class Shell
|
|
13
|
+
include Retries
|
|
14
|
+
|
|
15
|
+
SHORT_COMMAND_SEC = 120
|
|
16
|
+
POLL_INTERVAL_SEC = 2
|
|
17
|
+
MAX_OUTPUT_BYTES = 200_000
|
|
18
|
+
HOUSEKEEPING_TIMEOUT = 60
|
|
19
|
+
|
|
20
|
+
def initialize(sandbox)
|
|
21
|
+
@sandbox = sandbox
|
|
22
|
+
@session_id = fresh_session_id
|
|
23
|
+
sandbox.process.create_session(@session_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exec(command, timeout: nil, env: {})
|
|
27
|
+
validate_env!(env)
|
|
28
|
+
started = now
|
|
29
|
+
response =
|
|
30
|
+
if timeout && timeout > SHORT_COMMAND_SEC
|
|
31
|
+
exec_in_session(command, timeout: timeout, env: env)
|
|
32
|
+
else
|
|
33
|
+
exec_directly(command, timeout: timeout, env: env)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Base::ExecResult.new(command: command, duration_sec: (now - started).round(3), **response)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :sandbox
|
|
42
|
+
|
|
43
|
+
# Both transports must accept the same env: the session path serializes
|
|
44
|
+
# keys into `export` lines, where a key that is not a shell identifier
|
|
45
|
+
# would fail silently and run the command without its variable.
|
|
46
|
+
def validate_env!(env)
|
|
47
|
+
env.each_key do |key|
|
|
48
|
+
next if key.to_s.match?(/\A[A-Za-z_]\w*\z/)
|
|
49
|
+
|
|
50
|
+
raise ConfigError, "environment variable #{key.inspect} is not a shell identifier"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def exec_directly(command, timeout:, env:)
|
|
55
|
+
response = sandbox.process.exec(
|
|
56
|
+
command: command,
|
|
57
|
+
env: env.empty? ? nil : env,
|
|
58
|
+
timeout: timeout&.to_i
|
|
59
|
+
)
|
|
60
|
+
{ exit_code: response.exit_code, output: response.result.to_s }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exec_in_session(command, timeout:, env:)
|
|
64
|
+
run_id = SecureRandom.hex(6)
|
|
65
|
+
status_file = "/tmp/lemans-#{run_id}.status"
|
|
66
|
+
log_file = "/tmp/lemans-#{run_id}.log"
|
|
67
|
+
exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
|
|
68
|
+
|
|
69
|
+
sandbox.process.execute_session_command(
|
|
70
|
+
session_id: @session_id,
|
|
71
|
+
req: ::Daytona::SessionExecuteRequest.new(
|
|
72
|
+
# A subshell, not a brace group: `set -e`/`exit` would take the
|
|
73
|
+
# session's own shell down and leave the status line unwritten.
|
|
74
|
+
command: "(\n#{exports}\n#{command}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
|
|
75
|
+
"echo $? > #{Shellwords.escape(status_file)}",
|
|
76
|
+
run_async: true
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
exit_code = nil
|
|
81
|
+
begin
|
|
82
|
+
exit_code = await_status(status_file, timeout)
|
|
83
|
+
{ exit_code: exit_code || 124, output: tail(log_file) }
|
|
84
|
+
ensure
|
|
85
|
+
# On every exit path, including a poll that died: a command that
|
|
86
|
+
# outran its budget must not keep running, and the scratch files
|
|
87
|
+
# must not stay for the model to find. Session torn down first,
|
|
88
|
+
# for the best odds the command is dead before the rm runs.
|
|
89
|
+
terminate_session! if exit_code.nil?
|
|
90
|
+
clear_scratch(log_file, status_file)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def clear_scratch(*paths)
|
|
95
|
+
command = "rm -f #{paths.map { Shellwords.escape(_1) }.join(" ")}"
|
|
96
|
+
exec_directly(command, timeout: HOUSEKEEPING_TIMEOUT, env: {})
|
|
97
|
+
rescue *SDK_ERRORS => e
|
|
98
|
+
warn "lemans: could not clear #{paths.join(", ")}: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def terminate_session!
|
|
102
|
+
sandbox.process.delete_session(@session_id)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
warn "lemans: could not delete session #{@session_id}: #{e.message}"
|
|
105
|
+
ensure
|
|
106
|
+
@session_id = fresh_session_id
|
|
107
|
+
begin
|
|
108
|
+
sandbox.process.create_session(@session_id)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
warn "lemans: could not open a replacement session: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def await_status(status_file, timeout)
|
|
115
|
+
deadline = now + timeout
|
|
116
|
+
poll = "cat #{Shellwords.escape(status_file)} 2>/dev/null"
|
|
117
|
+
loop do
|
|
118
|
+
status = with_read_retries { exec_directly(poll, timeout: HOUSEKEEPING_TIMEOUT, env: {}) }
|
|
119
|
+
value = status[:output].to_s.strip
|
|
120
|
+
return Integer(value) if value.match?(/\A\d+\z/)
|
|
121
|
+
return nil if now > deadline
|
|
122
|
+
|
|
123
|
+
sleep POLL_INTERVAL_SEC
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def tail(log_file, bytes: MAX_OUTPUT_BYTES)
|
|
128
|
+
with_read_retries do
|
|
129
|
+
exec_directly("tail -c #{bytes} #{Shellwords.escape(log_file)} 2>/dev/null",
|
|
130
|
+
timeout: HOUSEKEEPING_TIMEOUT, env: {})
|
|
131
|
+
# Scrubbed where bytes become a string: tail -c cuts on a byte
|
|
132
|
+
# boundary and can split a multibyte character.
|
|
133
|
+
end[:output].to_s.scrub
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fresh_session_id = "lemans-#{SecureRandom.hex(6)}"
|
|
137
|
+
|
|
138
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "daytona"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module Lemans
|
|
9
|
+
module Environments
|
|
10
|
+
class Daytona
|
|
11
|
+
# Content-named snapshots: one build per image digest × resource shape,
|
|
12
|
+
# reused forever after.
|
|
13
|
+
class SnapshotStore
|
|
14
|
+
include Retries
|
|
15
|
+
|
|
16
|
+
POLL_INTERVAL_SEC = 2
|
|
17
|
+
|
|
18
|
+
SNAPSHOT_FAILED = [
|
|
19
|
+
::DaytonaApiClient::SnapshotState::ERROR,
|
|
20
|
+
::DaytonaApiClient::SnapshotState::BUILD_FAILED
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Two trials that want the same missing snapshot must not both build
|
|
24
|
+
# it; across processes the name collision settles it and the loser waits.
|
|
25
|
+
LOCKS = Concurrent::Map.new
|
|
26
|
+
|
|
27
|
+
def self.lock(name)
|
|
28
|
+
LOCKS.compute_if_absent(name) { Mutex.new }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(client:, image:, resources:, build_timeout_sec:, logger: nil)
|
|
32
|
+
@client = client
|
|
33
|
+
@image = image
|
|
34
|
+
@resources = resources
|
|
35
|
+
@build_timeout_sec = build_timeout_sec
|
|
36
|
+
@logger = logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
self.class.lock(name).synchronize do
|
|
41
|
+
existing = find(name)
|
|
42
|
+
existing = discard(existing) if existing && SNAPSHOT_FAILED.include?(existing.state)
|
|
43
|
+
existing ? await_ready(existing) : build(name)
|
|
44
|
+
end
|
|
45
|
+
name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The lookup name and the whole reuse policy: image digest plus resource
|
|
49
|
+
# shape, because a sandbox inherits resources from its snapshot.
|
|
50
|
+
def name
|
|
51
|
+
@name ||= begin
|
|
52
|
+
shape = [image.digest, resources.cpus, resources.memory_mb, resources.storage_mb].join(":")
|
|
53
|
+
"lemans-#{Digest::SHA256.hexdigest(shape)[0, 32]}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :client, :image, :resources, :build_timeout_sec, :logger
|
|
60
|
+
|
|
61
|
+
# A failed build keeps the name, so it would wedge every future trial
|
|
62
|
+
# sharing that image. The state is terminal; rebuilding is all that is left.
|
|
63
|
+
def discard(snapshot)
|
|
64
|
+
client.snapshot.delete(snapshot)
|
|
65
|
+
|
|
66
|
+
deadline = now + build_timeout_sec
|
|
67
|
+
while find(name)
|
|
68
|
+
raise InfrastructureError, "daytona: failed snapshot #{name} would not go away" if now > deadline
|
|
69
|
+
|
|
70
|
+
sleep POLL_INTERVAL_SEC
|
|
71
|
+
end
|
|
72
|
+
nil
|
|
73
|
+
rescue *SDK_ERRORS => e
|
|
74
|
+
# Another process discarding the same failed snapshot got there
|
|
75
|
+
# first: gone is the goal state, same as find's 404 policy.
|
|
76
|
+
return nil if status_code(e) == 404
|
|
77
|
+
|
|
78
|
+
raise InfrastructureError, "daytona: could not remove failed snapshot #{name}: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find(name)
|
|
82
|
+
with_read_retries { client.snapshot.get(name) }
|
|
83
|
+
rescue *SDK_ERRORS => e
|
|
84
|
+
return nil if status_code(e) == 404
|
|
85
|
+
|
|
86
|
+
raise InfrastructureError, "daytona: could not look up snapshot #{name}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The SDK's blocking create has no budget of its own, so
|
|
90
|
+
# environment.build_timeout is enforced here or nowhere.
|
|
91
|
+
def build(name)
|
|
92
|
+
params = ::Daytona::CreateSnapshotParams.new(
|
|
93
|
+
name: name,
|
|
94
|
+
# A published reference goes in as a one-line `FROM`, not a bare name:
|
|
95
|
+
# Daytona mangles a bare name's `@sha256:` pin into an invalid reference.
|
|
96
|
+
image: image.built? ? ::Daytona::Image.from_dockerfile(image.dockerfile_path.to_s) : base_image,
|
|
97
|
+
resources: daytona_resources
|
|
98
|
+
)
|
|
99
|
+
Timeout.timeout(build_timeout_sec, InfrastructureError,
|
|
100
|
+
"daytona: snapshot #{name} did not build within #{build_timeout_sec}s") do
|
|
101
|
+
client.snapshot.create(params, on_logs: logger)
|
|
102
|
+
end
|
|
103
|
+
rescue *SDK_ERRORS => e
|
|
104
|
+
unless status_code(e) == 409
|
|
105
|
+
raise InfrastructureError,
|
|
106
|
+
"daytona: could not build snapshot #{name}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Another process reached the same missing snapshot first; this trial
|
|
110
|
+
# waits for its build rather than failing.
|
|
111
|
+
taken = find(name)
|
|
112
|
+
raise InfrastructureError, "daytona: snapshot #{name} was taken and then vanished" if taken.nil?
|
|
113
|
+
|
|
114
|
+
await_ready(taken)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A snapshot that exists may still be building for whoever won the race,
|
|
118
|
+
# or deactivated from disuse — a weekly run will hit that.
|
|
119
|
+
def await_ready(snapshot)
|
|
120
|
+
deadline = now + build_timeout_sec
|
|
121
|
+
activated = false
|
|
122
|
+
|
|
123
|
+
loop do
|
|
124
|
+
state = snapshot.state
|
|
125
|
+
return if state == ::DaytonaApiClient::SnapshotState::ACTIVE
|
|
126
|
+
|
|
127
|
+
raise InfrastructureError, "daytona: snapshot #{name} is #{state}: #{snapshot.error_reason}" if SNAPSHOT_FAILED.include?(state)
|
|
128
|
+
|
|
129
|
+
if state == ::DaytonaApiClient::SnapshotState::INACTIVE && !activated
|
|
130
|
+
client.snapshot.activate(snapshot)
|
|
131
|
+
activated = true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
raise InfrastructureError, "daytona: snapshot #{name} was still #{state} after #{build_timeout_sec}s" if now > deadline
|
|
135
|
+
|
|
136
|
+
sleep POLL_INTERVAL_SEC
|
|
137
|
+
snapshot = find(name)
|
|
138
|
+
if snapshot.nil?
|
|
139
|
+
raise InfrastructureError,
|
|
140
|
+
"daytona: snapshot #{name} vanished while it was being waited on"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
rescue *SDK_ERRORS => e
|
|
144
|
+
raise InfrastructureError, "daytona: could not wait for snapshot #{name}: #{e.message}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def base_image = ::Daytona::Image.base(image.reference)
|
|
148
|
+
|
|
149
|
+
def daytona_resources
|
|
150
|
+
::Daytona::Resources.new(
|
|
151
|
+
cpu: resources.cpus,
|
|
152
|
+
memory: to_gib(resources.memory_mb),
|
|
153
|
+
disk: to_gib(resources.storage_mb)
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def to_gib(megabytes) = [(megabytes / 1024.0).ceil, 1].max
|
|
158
|
+
|
|
159
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|