Class: LLMExperiment::Container

Inherits:
Object
  • Object
show all
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 status prints one row:

ID        IMAGE     STATE    ADDR          CPUS  MEMORY
buildkit  ...:0.6.1  running  192.168.64.3  6     8192 MB

Memory 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

Instance Method Summary collapse

Constructor Details

#initialize(shell: Shell) ⇒ Container

Returns a new instance of Container.



9
10
11
# File 'lib/llm_experiment/container.rb', line 9

def initialize(shell: Shell)
  @shell = shell
end

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_statusObject



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_versionObject



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_allObject



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.

Raises:



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_gbObject



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

Returns:

  • (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

#imagesObject

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, **options)
  @shell.sh(*run_argv(image, script, **options))
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

Returns:

  • (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