Class: LLMExperiment::ImageBuilder::App
- Inherits:
-
Object
- Object
- LLMExperiment::ImageBuilder::App
- Defined in:
- lib/llm_experiment/image_builder/app.rb
Overview
Builds a per-app image on top of the base image, so a trial starts instantly
instead of paying for bundle install and database setup every time.
The app arrives as a git bundle containing only the experiment branches. That matters: a bundle carries committed history and nothing else, so untracked secrets, stale local bundler overrides and repository hooks on the host can never reach the image.
The image also strips the repository of anything that would let an agent read the answer instead of finding it. See FLATTENING below.
BRANCH NAMING
The source branches are not created here. The experiment's own planting script owns them, and this builder expects exactly these names:
<branch_prefix>/base the app as it ships
<branch_prefix>/<task id> one branch per task in experiment.yml
branch_prefix comes from the app's entry in experiment.yml. Nothing else
in the name is read, and nothing else may be encoded in it: the harness this
was ported from named branches exp/path-hints/bug-campfire-01-nat64-recheck
and the trailing slug handed the agent the answer. A missing branch is an
error, never a silently skipped task.
Images holding private code are never pushed to a registry.
Constant Summary collapse
- BUILD_ROOT =
ENV.fetch("LLMX_BUILD_ROOT", "/tmp/llmx/build")
Instance Method Summary collapse
-
#branch_map(app) ⇒ Object
FLATTENING.
- #build(app:, no_cache: false) ⇒ Object
-
#containerfile_for(app, branch_map) ⇒ Object
Pure: builds the Containerfile text from the app and its branch map, so the generated recipe can be read and tested without a container or a checkout.
-
#initialize(experiment:, container: Container.new, shell: Shell) ⇒ App
constructor
A new instance of App.
Constructor Details
#initialize(experiment:, container: Container.new, shell: Shell) ⇒ App
Returns a new instance of App.
37 38 39 40 41 |
# File 'lib/llm_experiment/image_builder/app.rb', line 37 def initialize(experiment:, container: Container.new, shell: Shell) @experiment = experiment @container = container @shell = shell end |
Instance Method Details
#branch_map(app) ⇒ Object
FLATTENING
Each experiment branch becomes one orphan commit called "Import application
source", renamed to an opaque trial/<task id>.
Three leaks close here, all of which would let an agent read the answer rather than search for it:
the commit message said "reintroduce the defect fixed in <sha>"
the commit diff was the real fix, inverted
the branch name carried the defect's slug, e.g. -nat64-recheck
Leaks like these do not just add noise, they bias: an agent given no path is
far likelier to go digging through history than one handed the path outright,
so the shortcut would help exactly the condition the experiment expects to be
slowest. The trial runner closes the fourth leak by deleting every branch
except the one under test, so git diff trial/base cannot reveal it either.
100 101 102 103 104 |
# File 'lib/llm_experiment/image_builder/app.rb', line 100 def branch_map(app) map = { "#{app.branch_prefix}/base" => "trial/base" } tasks_for(app).each { |task| map["#{app.branch_prefix}/#{task.id}"] = task.branch } map end |
#build(app:, no_cache: false) ⇒ Object
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/llm_experiment/image_builder/app.rb', line 43 def build(app:, no_cache: false) repo = app.repo_path if repo.nil? || repo.empty? raise ConfigError, "LLMX_APP_#{app.key.upcase} is not set; point it at the #{app.key} checkout on this machine" end @container.ensure_disk! @container.ensure_builder! unless @container.image?(LLMExperiment.base_image) raise Error, "base image #{LLMExperiment.base_image} not found; run llmx build base first" end context = File.join(BUILD_ROOT, app.key) FileUtils.rm_rf(context) FileUtils.mkdir_p(context) raise ConfigError, "#{app.key}: no tasks in experiment.yml use this app" if tasks_for(app).empty? map = branch_map(app) require_branches!(app, repo, map) bundle_path = File.join(context, "app.bundle") @shell.log "bundling #{map.size} branch(es) from #{app.key}" @shell.sh("git", "-C", repo, "bundle", "create", bundle_path, *map.keys) # -C names the repository. `git bundle verify` resolves the bundle's # prerequisites against a repository, and without -C it uses the working # directory, which for an experiment directory is usually not one. @shell.sh("git", "-C", repo, "bundle", "verify", bundle_path) containerfile = File.join(context, "Containerfile") File.write(containerfile, containerfile_for(app, map)) @shell.log "building #{app.image}" @container.build(tag: app.image, file: containerfile, context: context, build_args: { "APP_KEY" => app.key, "RUBY_VERSION" => app.ruby }, no_cache: no_cache) @shell.log "app image ready: #{app.image} (never push: it carries the subject app's code)" end |
#containerfile_for(app, branch_map) ⇒ Object
Pure: builds the Containerfile text from the app and its branch map, so the generated recipe can be read and tested without a container or a checkout.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/llm_experiment/image_builder/app.rb', line 108 def containerfile_for(app, branch_map) # Cloning a bundle materialises only the checked-out branch locally; the rest # arrive as origin/* refs, which disappear along with the remote. Create real # local branches first, then drop the remote. localise = branch_map.keys.map { |b| "git branch -f #{b} origin/#{b} 2>/dev/null || true;" } .join(" \\\n ") flatten = branch_map.map do |source, target| "git checkout -q #{source}; git checkout -q --orphan #{target}; " \ "#{neutralize_fragment(app)}" \ "git add -A; git commit -q -m 'Import application source';" end.join(" \\\n ") drop_originals = branch_map.keys.map { |b| "git branch -q -D #{b};" }.join(" ") containerfile = +<<~DOCKER FROM #{LLMExperiment.base_image} ARG APP_KEY ARG RUBY_VERSION ENV LLMX_APP=${APP_KEY} \\ MISE_RUBY_VERSION=${RUBY_VERSION} \\ RAILS_ENV=test USER agent WORKDIR /workspace COPY --chown=agent:agent app.bundle /home/agent/app.bundle RUN set -eux; \\ git clone --branch #{app.branch_prefix}/base /home/agent/app.bundle /workspace/app; \\ cd /workspace/app; \\ #{localise} \\ git remote remove origin; \\ git config user.email dev@example.invalid; \\ git config user.name Developer; \\ #{flatten} \\ #{drop_originals} \\ git checkout -q trial/base; \\ rm -f /home/agent/app.bundle; \\ git reflog expire --expire=now --all; \\ git gc --prune=now --quiet; \\ git branch; \\ test -z "$(git log --all --oneline --format='%s' | grep -viE '^Import application source$' || true)" WORKDIR /workspace/app DOCKER containerfile << postgres_block if app.database == "postgresql" containerfile << bundler_block(app) containerfile end |