Class: LLMExperiment::Container
- Inherits:
-
Object
- Object
- LLMExperiment::Container
- Defined in:
- lib/llm_experiment/container.rb
Overview
Wraps the Apple container CLI. Every invocation of the CLI in the gem
goes through here, so its quirks are handled in exactly one place.
Constant Summary collapse
- BUILDER_ROW =
The builder is a separate VM with its own envelope (2 CPUs / 2 GB by default), and a starved builder dies mid-build with no error at all.
container builder statusprints one row:ID IMAGE STATE ADDR CPUS MEMORY buildkit ...:0.6.1 running 192.168.64.3 6 8192 MBMemory is reported in MB and configured in G, so the two are compared as numbers rather than strings.
/^buildkit\s.*\brunning\s+\S+\s+(\d+)\s+(\d+\s*[KMGT]?B?)/i
Class Method Summary collapse
-
.megabytes(text) ⇒ Object
"8G", "8g", "8GB", "8192 MB" and "8192MB" all mean the same envelope.
Instance Method Summary collapse
- #build(tag:, file:, context:, build_args: {}, no_cache: false) ⇒ Object
- #builder_status ⇒ Object
- #cli_version ⇒ Object
- #containers_all ⇒ Object
- #delete_image(name) ⇒ Object
-
#ensure_builder! ⇒ Object
A starved builder dies mid-build with no error, so an existing builder is only reused when it meets the whole envelope.
-
#ensure_disk!(need_gb: LLMExperiment.min_free_gb) ⇒ Object
Running out of disk mid-build does not fail cleanly: the build dies with no message, and on a truly full disk even writing the error fails.
- #ensure_system! ⇒ Object
- #free_gb ⇒ Object
- #image?(name) ⇒ Boolean
-
#images ⇒ Object
The subcommand is
container image list;container imagesfails confusingly, which is why nothing else in the gem calls it directly. -
#initialize(shell: Shell) ⇒ Container
constructor
A new instance of Container.
- #require_cli! ⇒ Object
- #run(image, script, **options) ⇒ Object
-
#run_args(memory:, cpus:, mount_auth: true) ⇒ Object
Arguments common to every trial container: resource envelope plus the credential store mount.
-
#run_argv(image, script, memory:, cpus:, mount_auth: true, volumes: [], env: {}, workdir: nil, interactive: false) ⇒ Object
The full argv for one containerized command.
- #system_running? ⇒ Boolean
Constructor Details
Class Method Details
.megabytes(text) ⇒ Object
"8G", "8g", "8GB", "8192 MB" and "8192MB" all mean the same envelope. A bare number is read as MB, which is how the CLI reports it.
55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/llm_experiment/container.rb', line 55 def self.megabytes(text) value, unit = text.to_s.strip.match(/\A(\d+)\s*([KMGT]?)B?\z/i)&.captures return 0 unless value case unit.to_s.upcase when "G" then value.to_i * 1024 when "T" then value.to_i * 1024 * 1024 when "K" then value.to_i / 1024 else value.to_i end end |
Instance Method Details
#build(tag:, file:, context:, build_args: {}, no_cache: false) ⇒ Object
107 108 109 110 111 112 113 |
# File 'lib/llm_experiment/container.rb', line 107 def build(tag:, file:, context:, build_args: {}, no_cache: false) cmd = ["container", "build", "--tag", tag, "--file", file] build_args.each { |key, value| cmd += ["--build-arg", "#{key}=#{value}"] } cmd << "--no-cache" if no_cache cmd << context @shell.sh(*cmd) end |
#builder_status ⇒ Object
37 38 39 40 |
# File 'lib/llm_experiment/container.rb', line 37 def builder_status ensure_system! @shell.capture("container", "builder", "status", allow_failure: true) end |
#cli_version ⇒ Object
13 14 15 16 |
# File 'lib/llm_experiment/container.rb', line 13 def cli_version out = @shell.capture("container", "--version", allow_failure: true).strip out.empty? ? nil : out end |
#containers_all ⇒ Object
119 120 121 |
# File 'lib/llm_experiment/container.rb', line 119 def containers_all @shell.capture("container", "ls", "-a", allow_failure: true) end |
#delete_image(name) ⇒ Object
115 116 117 |
# File 'lib/llm_experiment/container.rb', line 115 def delete_image(name) @shell.sh("container", "image", "delete", name, allow_failure: true) end |
#ensure_builder! ⇒ Object
A starved builder dies mid-build with no error, so an existing builder is only reused when it meets the whole envelope. Checking CPUs alone accepted a builder with 6 CPUs and 2 GB.
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/llm_experiment/container.rb', line 70 def ensure_builder! if (row = builder_status.match(BUILDER_ROW)) cpus = row[1].to_i memory_mb = self.class.megabytes(row[2]) want_cpus = LLMExperiment.builder_cpus.to_i want_mb = self.class.megabytes(LLMExperiment.builder_memory) return true if cpus >= want_cpus && memory_mb >= want_mb @shell.log "builder has #{cpus} CPUs and #{memory_mb} MB; " \ "restarting with #{want_cpus} CPUs and #{LLMExperiment.builder_memory}" @shell.sh("container", "builder", "stop", allow_failure: true) end @shell.log "starting image builder (#{LLMExperiment.builder_cpus} CPUs, #{LLMExperiment.builder_memory})" @shell.sh("container", "builder", "start", "--cpus", LLMExperiment.builder_cpus, "--memory", LLMExperiment.builder_memory) true end |
#ensure_disk!(need_gb: LLMExperiment.min_free_gb) ⇒ Object
Running out of disk mid-build does not fail cleanly: the build dies with no message, and on a truly full disk even writing the error fails.
130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/llm_experiment/container.rb', line 130 def ensure_disk!(need_gb: LLMExperiment.min_free_gb) free = free_gb return true if free >= need_gb raise ContainerError, <<~MSG Only #{free} GB free; this build needs about #{need_gb} GB. Most of that is usually the BuildKit builder VM rather than any image: llmx clean report what is on disk, delete nothing llmx clean --builder drop the builder cache, which is regenerable MSG end |
#ensure_system! ⇒ Object
28 29 30 31 32 33 34 35 |
# File 'lib/llm_experiment/container.rb', line 28 def ensure_system! require_cli! return true if system_running? @shell.log "starting container system" @shell.sh("container", "system", "start") true end |
#free_gb ⇒ Object
123 124 125 126 |
# File 'lib/llm_experiment/container.rb', line 123 def free_gb line = @shell.capture("df", "-g", "/System/Volumes/Data", allow_failure: true).lines.last.to_s line.split[3].to_i end |
#image?(name) ⇒ Boolean
101 102 103 104 105 |
# File 'lib/llm_experiment/container.rb', line 101 def image?(name) repo, tag = name.split(":", 2) tag ||= "latest" images.include?("#{repo}:#{tag}") end |
#images ⇒ Object
The subcommand is container image list; container images fails
confusingly, which is why nothing else in the gem calls it directly.
92 93 94 95 96 97 98 99 |
# File 'lib/llm_experiment/container.rb', line 92 def images @shell.capture("container", "image", "list", allow_failure: true) .lines.drop(1) .filter_map do |line| repo, tag = line.split "#{repo}:#{tag}" if repo && tag end end |
#require_cli! ⇒ Object
18 19 20 21 |
# File 'lib/llm_experiment/container.rb', line 18 def require_cli! cli_version or raise ContainerError, "Apple `container` CLI not found. Install it, then run `container system start`." end |
#run(image, script, **options) ⇒ Object
174 175 176 |
# File 'lib/llm_experiment/container.rb', line 174 def run(image, script, **) @shell.sh(*run_argv(image, script, **)) end |
#run_args(memory:, cpus:, mount_auth: true) ⇒ Object
Arguments common to every trial container: resource envelope plus the credential store mount. The store is a seed, not a home — runners copy credentials out of it and never write back.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/llm_experiment/container.rb', line 145 def run_args(memory:, cpus:, mount_auth: true) args = ["--memory", memory, "--cpus", cpus.to_s] if mount_auth %w[claude codex opencode].each do |agent| FileUtils.mkdir_p(File.join(LLMExperiment.auth_dir, agent)) end home = "/home/#{LLMExperiment.agent_user}" args += ["--volume", "#{LLMExperiment.auth_dir}:#{home}/.agent-auth"] args += ["--env", "CLAUDE_CONFIG_DIR=#{home}/.agent-auth/claude"] args += ["--env", "CODEX_HOME=#{home}/.agent-auth/codex"] args += ["--env", "LLMX_OPENCODE_AUTH=#{home}/.agent-auth/opencode"] end args end |
#run_argv(image, script, memory:, cpus:, mount_auth: true, volumes: [], env: {}, workdir: nil, interactive: false) ⇒ Object
The full argv for one containerized command. Always through a login
shell: container run does not apply the image's ENV PATH, and the
rubies are mise shims.
163 164 165 166 167 168 169 170 171 172 |
# File 'lib/llm_experiment/container.rb', line 163 def run_argv(image, script, memory:, cpus:, mount_auth: true, volumes: [], env: {}, workdir: nil, interactive: false) argv = ["container", "run", "--rm"] argv += ["--interactive", "--tty"] if interactive argv += run_args(memory: memory, cpus: cpus, mount_auth: mount_auth) volumes.each { |volume| argv += ["--volume", volume] } env.each { |key, value| argv += ["--env", "#{key}=#{value}"] } argv += ["--workdir", workdir] if workdir argv + [image, "bash", "-lc", script] end |
#system_running? ⇒ Boolean
23 24 25 26 |
# File 'lib/llm_experiment/container.rb', line 23 def system_running? @shell.capture("container", "system", "status", allow_failure: true) .include?("apiserver is running") end |