lemans 0.0.0.pre → 0.2.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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/exe/lemans-remote +984 -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 +678 -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 +161 -7
|
@@ -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
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
require "daytona"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
module Environments
|
|
9
|
+
# Daytona sandboxes. Daytona builds images server-side into reusable content-named
|
|
10
|
+
# snapshots and enforces the network policy itself.
|
|
11
|
+
class Daytona < Base
|
|
12
|
+
TTL_MINUTES = 120
|
|
13
|
+
|
|
14
|
+
DEFAULT_BUILD_TIMEOUT_SEC = 600
|
|
15
|
+
|
|
16
|
+
# Workspace tarballs ride uploads/downloads, so transfers get their own
|
|
17
|
+
# budget through the SDK's streaming API instead of the global HTTP cap.
|
|
18
|
+
TRANSFER_TIMEOUT = 900
|
|
19
|
+
|
|
20
|
+
# File transfers ride the SDK's typhoeus/libcurl stack, which segfaults
|
|
21
|
+
# the VM under enough concurrent easy_perform calls (a GC race on string
|
|
22
|
+
# options libcurl is still copying). Transfers are seconds each, so
|
|
23
|
+
# capping them costs little; execs and lifecycle stay fully parallel.
|
|
24
|
+
TRANSFER_SLOTS = Concurrent::Semaphore.new(6)
|
|
25
|
+
|
|
26
|
+
SdkTweaks.apply!
|
|
27
|
+
|
|
28
|
+
attr_reader :sandbox
|
|
29
|
+
|
|
30
|
+
# Trials run concurrently, and an unsynchronised memo would build several
|
|
31
|
+
# SDK clients and leak all but one.
|
|
32
|
+
CLIENT = Concurrent::Delay.new { ::Daytona::Daytona.new(credentials) }
|
|
33
|
+
|
|
34
|
+
def self.client = CLIENT.value!
|
|
35
|
+
|
|
36
|
+
# The CLI stores its key as DAYTONA_TOKEN, the SDK only reads
|
|
37
|
+
# DAYTONA_API_KEY; Config also resolves .env files, so go through it.
|
|
38
|
+
def self.credentials
|
|
39
|
+
config = ::Daytona::Config.new
|
|
40
|
+
config.api_key ||= config.read_env("DAYTONA_TOKEN")
|
|
41
|
+
raise ConfigError, "no Daytona credentials: set DAYTONA_API_KEY or DAYTONA_TOKEN" unless config.api_key || config.jwt_token
|
|
42
|
+
|
|
43
|
+
config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout_sec: nil)
|
|
47
|
+
super(image: image, resources: resources, network: network, env: env, labels: labels,
|
|
48
|
+
build_timeout_sec: build_timeout_sec || DEFAULT_BUILD_TIMEOUT_SEC)
|
|
49
|
+
@logger = logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
@sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
|
|
54
|
+
@shell = Shell.new(sandbox)
|
|
55
|
+
self
|
|
56
|
+
rescue *Retries::SDK_ERRORS => e
|
|
57
|
+
# A sandbox created but never handed over would bill until its TTL:
|
|
58
|
+
# the caller's ensure can only stop an environment it received.
|
|
59
|
+
stop
|
|
60
|
+
raise InfrastructureError, "daytona: could not start sandbox: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exec(command, timeout: nil, env: {})
|
|
64
|
+
@shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
|
|
65
|
+
rescue *Retries::SDK_ERRORS => e
|
|
66
|
+
raise InfrastructureError, "daytona: exec failed: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def upload(local_path, remote_path)
|
|
70
|
+
transfer do
|
|
71
|
+
# An open handle, not a path string: the SDK uploads a non-existent
|
|
72
|
+
# path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
|
|
73
|
+
Pathname(local_path).open("rb") do |file|
|
|
74
|
+
sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rescue *Retries::SDK_ERRORS => e
|
|
78
|
+
raise InfrastructureError, "daytona: could not upload #{local_path}: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def download(remote_path, local_path)
|
|
82
|
+
local_path = Pathname(local_path)
|
|
83
|
+
local_path.dirname.mkpath
|
|
84
|
+
transfer do
|
|
85
|
+
local_path.open("wb") do |file|
|
|
86
|
+
sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
rescue *Retries::SDK_ERRORS, SystemCallError => e
|
|
90
|
+
raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def network_policy=(policy)
|
|
94
|
+
sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
|
|
95
|
+
@network = policy
|
|
96
|
+
rescue *Retries::SDK_ERRORS => e
|
|
97
|
+
raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Deleting is the only cleanup that stops the meter. Never raises:
|
|
101
|
+
# cleanup must not replace an otherwise valid result with an exception.
|
|
102
|
+
def stop
|
|
103
|
+
return if @sandbox.nil?
|
|
104
|
+
|
|
105
|
+
id = @sandbox.id
|
|
106
|
+
wait = true
|
|
107
|
+
begin
|
|
108
|
+
@sandbox.delete(wait:)
|
|
109
|
+
@sandbox = nil
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
# VM shutdown: confirming destruction needs threads Ruby no longer
|
|
112
|
+
# grants, but the bare DELETE needs none — the meter still stops.
|
|
113
|
+
if e.is_a?(ThreadError) && wait
|
|
114
|
+
wait = false
|
|
115
|
+
retry
|
|
116
|
+
end
|
|
117
|
+
warn "lemans: sandbox #{id} may still be running — delete failed: #{e.class}: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def transfer
|
|
124
|
+
TRANSFER_SLOTS.acquire
|
|
125
|
+
yield
|
|
126
|
+
ensure
|
|
127
|
+
TRANSFER_SLOTS.release
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def client = self.class.client
|
|
131
|
+
|
|
132
|
+
# A sandbox inherits the snapshot's resources, so the profile's are
|
|
133
|
+
# stamped into the snapshot at build time.
|
|
134
|
+
def create_params
|
|
135
|
+
::Daytona::CreateSandboxFromSnapshotParams.new(
|
|
136
|
+
snapshot: snapshot_store.call,
|
|
137
|
+
env_vars: env,
|
|
138
|
+
labels: labels,
|
|
139
|
+
auto_stop_interval: 0, # a 30-minute agent must not be stopped under it
|
|
140
|
+
auto_delete_interval: 60,
|
|
141
|
+
# A real ceiling: without it a harness that dies mid-run leaves a
|
|
142
|
+
# running sandbox billing forever.
|
|
143
|
+
ttl_minutes: TTL_MINUTES,
|
|
144
|
+
**network_kwargs(network)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def snapshot_store
|
|
149
|
+
SnapshotStore.new(client: client, image: image, resources: resources,
|
|
150
|
+
build_timeout_sec: build_timeout_sec, logger: @logger)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def network_kwargs(policy, for_update: false)
|
|
154
|
+
case policy.mode
|
|
155
|
+
when :none
|
|
156
|
+
{ network_block_all: true }
|
|
157
|
+
when :public
|
|
158
|
+
for_update ? { network_block_all: false } : {}
|
|
159
|
+
when :allowlist
|
|
160
|
+
raise ConfigError, "daytona: an allowlist cannot mix domains and IP targets (#{policy.hosts.join(", ")})" if policy.domains.any? && policy.ip_targets.any?
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
network_block_all: false,
|
|
164
|
+
domain_allow_list: policy.domains.join(","),
|
|
165
|
+
network_allow_list: policy.ip_targets.join(",")
|
|
166
|
+
}.reject { |_, value| value == "" }
|
|
167
|
+
else
|
|
168
|
+
# Silently returning nil would launch under Daytona's default
|
|
169
|
+
# network — the opposite of what this method exists to prevent.
|
|
170
|
+
raise ConfigError, "daytona: unsupported network mode #{policy.mode.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
# The backends `lemans run --backend` can name, and the one place a name
|
|
5
|
+
# becomes a class. An unknown backend fails as a config mistake.
|
|
6
|
+
module Environments
|
|
7
|
+
BACKENDS = { "daytona" => "Daytona" }.freeze
|
|
8
|
+
|
|
9
|
+
def self.build(backend, **)
|
|
10
|
+
constant = BACKENDS[backend.to_s] or
|
|
11
|
+
raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
|
|
12
|
+
|
|
13
|
+
const_get(constant).new(**)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# What a phase is allowed to reach. Every phase names its policy explicitly:
|
|
7
|
+
# network: { mode: allowlist, hosts: [openrouter.ai, "*.example.com", 10.0.0.0/8] }
|
|
8
|
+
class NetworkPolicy
|
|
9
|
+
MODES = %i[none allowlist public].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :mode, :hosts, :domains, :ip_targets
|
|
12
|
+
|
|
13
|
+
def self.from_config(config, field:)
|
|
14
|
+
raise ConfigError, "#{field}: network policy is required" if config.nil?
|
|
15
|
+
|
|
16
|
+
mode = config["mode"] or raise ConfigError, "#{field}.mode is required (#{MODES.join(", ")})"
|
|
17
|
+
new(mode: mode.to_s.to_sym, hosts: config["hosts"] || [], field: field)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.none = new(mode: :none)
|
|
21
|
+
|
|
22
|
+
def initialize(mode:, hosts: [], field: "network")
|
|
23
|
+
unless MODES.include?(mode)
|
|
24
|
+
raise ConfigError,
|
|
25
|
+
"#{field}.mode: #{mode.inspect} is not one of #{MODES.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise ConfigError, "#{field}.hosts must be a list" unless hosts.is_a?(Array)
|
|
29
|
+
if mode != :allowlist && !hosts.empty?
|
|
30
|
+
raise ConfigError,
|
|
31
|
+
"#{field}.hosts is only meaningful with mode: allowlist"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise ConfigError, "#{field}.hosts cannot be empty with mode: allowlist" if mode == :allowlist && hosts.empty?
|
|
35
|
+
|
|
36
|
+
hosts = validated_hosts(hosts, field)
|
|
37
|
+
|
|
38
|
+
@mode = mode
|
|
39
|
+
@hosts = hosts.freeze
|
|
40
|
+
# Split once, at construction: backends allowlist domains and IP ranges
|
|
41
|
+
# through separate APIs, and a bad entry must fail here, loudly — a
|
|
42
|
+
# malformed allowlist must never launch a sandbox open.
|
|
43
|
+
@ip_targets, @domains = hosts.partition { ip_target?(_1) }.map(&:freeze)
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h = { mode: mode, hosts: hosts }
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validated_hosts(hosts, field)
|
|
52
|
+
hosts.map do |entry|
|
|
53
|
+
raise ConfigError, "#{field}.hosts entry #{entry.inspect} is not a host name, pattern, or IP range" unless entry.is_a?(String) && !entry.strip.empty?
|
|
54
|
+
|
|
55
|
+
entry.strip
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ip_target?(entry)
|
|
60
|
+
IPAddr.new(entry)
|
|
61
|
+
true
|
|
62
|
+
rescue IPAddr::Error
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/lemans/patch.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# The agent's work as one git patch, diffed against a baseline sealed before
|
|
8
|
+
# its first turn
|
|
9
|
+
class Patch
|
|
10
|
+
LOCAL_PATH = "agent.patch"
|
|
11
|
+
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
12
|
+
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
13
|
+
|
|
14
|
+
TIMEOUT = 300
|
|
15
|
+
|
|
16
|
+
def initialize(environment, bench:, dir:)
|
|
17
|
+
@environment = environment
|
|
18
|
+
@workdir = bench.environment.workdir
|
|
19
|
+
@path = Pathname(dir).join(LOCAL_PATH)
|
|
20
|
+
@baseline = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def seal!
|
|
24
|
+
@baseline = write_tree
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
28
|
+
# after would not show what the agent did to them
|
|
29
|
+
def collect!
|
|
30
|
+
return unless baseline
|
|
31
|
+
|
|
32
|
+
after = write_tree
|
|
33
|
+
return unless after
|
|
34
|
+
|
|
35
|
+
result = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout: TIMEOUT)
|
|
36
|
+
return unless result.success?
|
|
37
|
+
|
|
38
|
+
path.dirname.mkpath
|
|
39
|
+
environment.download(REMOTE_PATCH, path)
|
|
40
|
+
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout: TIMEOUT)
|
|
41
|
+
path
|
|
42
|
+
rescue InfrastructureError => e
|
|
43
|
+
warn "lemans: could not collect the agent patch for #{path.dirname.basename}: #{e.message}"
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :environment, :workdir, :path, :baseline
|
|
50
|
+
|
|
51
|
+
# `safe.directory` because the sandbox may run the tree as a different user
|
|
52
|
+
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
53
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
54
|
+
|
|
55
|
+
# Untracked files only reach a diff through an index, so both sides are
|
|
56
|
+
# staged into a scratch one and hashed. Rebuilt from empty each time, so a
|
|
57
|
+
# stale entry cannot survive into the second tree.
|
|
58
|
+
def write_tree
|
|
59
|
+
result = environment.exec(
|
|
60
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
61
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
|
|
62
|
+
timeout: TIMEOUT
|
|
63
|
+
)
|
|
64
|
+
return nil unless result.success?
|
|
65
|
+
|
|
66
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
67
|
+
tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module RestorePaths # :nodoc:
|
|
7
|
+
def self.call(declared, label:)
|
|
8
|
+
Array(declared).each_with_index.map do |path, index|
|
|
9
|
+
entry = "#{label}[#{index}]"
|
|
10
|
+
raise ConfigError, "#{entry} must be a path string, got #{path.inspect}" unless path.is_a?(String)
|
|
11
|
+
raise ConfigError, "#{entry} must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
|
|
12
|
+
raise ConfigError, "#{entry} must not escape the workdir: #{path.inspect}" if
|
|
13
|
+
path.split("/").include?("..")
|
|
14
|
+
raise ConfigError, "#{entry} must name something inside the workdir, got #{path.inspect}" if
|
|
15
|
+
Pathname(path).cleanpath.to_s == "."
|
|
16
|
+
|
|
17
|
+
path
|
|
18
|
+
end.freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|