feat(bottle): per-bottle Dockerfile state + image build hook (PRD 0016)
Phase 1 of PRD 0016. Lays the per-bottle state plumbing that capability-block remediation will write into: - claude_bottle/backend/docker/bottle_state.py: bottle_state_dir, per_bottle_dockerfile (read), write_per_bottle_dockerfile, per_bottle_image_tag (unique per slug), transcript_snapshot_dir. Stores under ~/.claude-bottle/state/<slug>/. - prepare.py: when a per-bottle Dockerfile exists, use per_bottle_image_tag(slug) as the base image and pass the per-bottle Dockerfile path through DockerBottlePlan.dockerfile_path. --cwd still layers a derived image on top. - launch.py: passes plan.dockerfile_path to build_image so the per-bottle Dockerfile is what docker build reads. - DockerBottlePlan gains dockerfile_path field; print() surfaces it in the preflight summary so the operator can see at-a-glance that this bottle is running on a rebuilt image. Phase 2 will write to write_per_bottle_dockerfile (capability-block approval); Phase 3 wires it into the dashboard. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,11 @@ class DockerBottlePlan(BottlePlan):
|
||||
image: str
|
||||
derived_image: str # "" -> no derived image
|
||||
runtime_image: str # image actually launched (derived or base)
|
||||
# Absolute path to the Dockerfile that builds `image`. Empty means
|
||||
# use the repo's default Dockerfile. Populated to a per-bottle
|
||||
# state file (~/.claude-bottle/state/<slug>/Dockerfile) after a
|
||||
# capability-block remediation (PRD 0016).
|
||||
dockerfile_path: str
|
||||
env_file: Path # docker --env-file: NAME=VALUE literals
|
||||
# name -> value for vars forwarded into the docker-run child process
|
||||
# via subprocess env (so values never land on argv or in a file).
|
||||
@@ -89,6 +94,11 @@ class DockerBottlePlan(BottlePlan):
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"image : {self.image}")
|
||||
if self.dockerfile_path:
|
||||
info(
|
||||
f"dockerfile : {self.dockerfile_path} "
|
||||
f"(per-bottle override from PRD 0016 capability rebuild)"
|
||||
)
|
||||
if self.derived_image:
|
||||
info(
|
||||
f"cwd : {spec.user_cwd} -> /home/node/workspace "
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Per-bottle persistent state (PRD 0016).
|
||||
|
||||
Holds the per-bottle Dockerfile override that capability-block
|
||||
remediation writes, plus the transcript snapshot the
|
||||
state-preservation helper saves before teardown. State lives at:
|
||||
|
||||
~/.claude-bottle/state/<slug>/
|
||||
Dockerfile — per-bottle override (absent → use repo's)
|
||||
transcript/ — last snapshotted agent state (best-effort)
|
||||
|
||||
When the per-bottle Dockerfile is present, the launch step builds
|
||||
the agent image with a per-bottle tag (claude-bottle-rebuilt-<slug>)
|
||||
from this file rather than the repo's. The build context is still
|
||||
the repo root so the Dockerfile can COPY claude_bottle source files
|
||||
the same way the original does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ... import supervise as _supervise
|
||||
|
||||
|
||||
# Directory layout: ~/.claude-bottle/state/<slug>/...
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
|
||||
|
||||
def bottle_state_dir(slug: str) -> Path:
|
||||
"""Per-bottle state directory on the host. Created lazily by the
|
||||
write helpers; readers tolerate its absence."""
|
||||
return _supervise.claude_bottle_root() / _STATE_SUBDIR / slug
|
||||
|
||||
|
||||
def per_bottle_dockerfile_path(slug: str) -> Path:
|
||||
return bottle_state_dir(slug) / _PER_BOTTLE_DOCKERFILE_NAME
|
||||
|
||||
|
||||
def per_bottle_dockerfile(slug: str) -> str | None:
|
||||
"""Return the per-bottle Dockerfile content if present, else
|
||||
None. None means: use the repo's Dockerfile (the original
|
||||
pre-capability-block behavior)."""
|
||||
p = per_bottle_dockerfile_path(slug)
|
||||
if p.is_file():
|
||||
return p.read_text()
|
||||
return None
|
||||
|
||||
|
||||
def write_per_bottle_dockerfile(slug: str, content: str) -> Path:
|
||||
p = per_bottle_dockerfile_path(slug)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content)
|
||||
p.chmod(0o644)
|
||||
return p
|
||||
|
||||
|
||||
def per_bottle_image_tag(slug: str) -> str:
|
||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||
claude-bottle:latest so per-bottle rebuilds don't collide in
|
||||
the docker image cache."""
|
||||
return f"claude-bottle-rebuilt-{slug}:latest"
|
||||
|
||||
|
||||
def transcript_snapshot_dir(slug: str) -> Path:
|
||||
"""Where capability_apply stashes the agent's transcript before
|
||||
teardown, so the next `cli.py start <agent>` can offer to
|
||||
resume from it."""
|
||||
return bottle_state_dir(slug) / _TRANSCRIPT_SUBDIR
|
||||
|
||||
|
||||
__all__ = [
|
||||
"bottle_state_dir",
|
||||
"per_bottle_dockerfile",
|
||||
"per_bottle_dockerfile_path",
|
||||
"per_bottle_image_tag",
|
||||
"transcript_snapshot_dir",
|
||||
"write_per_bottle_dockerfile",
|
||||
]
|
||||
@@ -66,7 +66,10 @@ def launch(
|
||||
pass
|
||||
|
||||
try:
|
||||
docker_mod.build_image(plan.image, _REPO_DIR)
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
if plan.derived_image:
|
||||
docker_mod.build_image_with_cwd(
|
||||
plan.derived_image, plan.image, plan.spec.user_cwd
|
||||
|
||||
@@ -26,6 +26,11 @@ from .cred_proxy import (
|
||||
cred_proxy_url,
|
||||
)
|
||||
from .git_gate import DockerGitGate, git_gate_container_name
|
||||
from .bottle_state import (
|
||||
per_bottle_dockerfile,
|
||||
per_bottle_dockerfile_path,
|
||||
per_bottle_image_tag,
|
||||
)
|
||||
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
||||
from .supervise import DockerSupervise, supervise_container_name
|
||||
|
||||
@@ -50,7 +55,17 @@ def resolve_plan(
|
||||
|
||||
slug = docker_mod.slugify(spec.agent_name)
|
||||
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
|
||||
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
|
||||
# written (via apply_capability_change), the base image becomes
|
||||
# per_bottle_image_tag(slug) built from that file. --cwd still
|
||||
# layers a derived image on top.
|
||||
dockerfile_path = ""
|
||||
if per_bottle_dockerfile(slug) is not None:
|
||||
image_default = per_bottle_image_tag(slug)
|
||||
dockerfile_path = str(per_bottle_dockerfile_path(slug))
|
||||
else:
|
||||
image_default = "claude-bottle:latest"
|
||||
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", image_default)
|
||||
derived_image = ""
|
||||
runtime_image = image
|
||||
if spec.copy_cwd:
|
||||
@@ -184,6 +199,7 @@ def resolve_plan(
|
||||
image=image,
|
||||
derived_image=derived_image,
|
||||
runtime_image=runtime_image,
|
||||
dockerfile_path=dockerfile_path,
|
||||
env_file=env_file,
|
||||
forwarded_env=forwarded_env,
|
||||
prompt_file=prompt_file,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Unit: per-bottle state helpers (PRD 0016 Phase 1)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle import supervise
|
||||
from claude_bottle.backend.docker import bottle_state
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
def _setup_fake_home(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.")
|
||||
original = supervise.claude_bottle_root
|
||||
|
||||
def fake_root() -> Path:
|
||||
return Path(self._tmp.name) / ".claude-bottle"
|
||||
|
||||
supervise.claude_bottle_root = fake_root # type: ignore[assignment]
|
||||
self._restore = lambda: setattr(supervise, "claude_bottle_root", original)
|
||||
|
||||
def _teardown_fake_home(self):
|
||||
self._restore()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class TestPerBottleDockerfile(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
self.assertIsNone(bottle_state.per_bottle_dockerfile("dev"))
|
||||
|
||||
def test_write_then_read_roundtrip(self):
|
||||
bottle_state.write_per_bottle_dockerfile(
|
||||
"dev", "FROM python:3.13\nRUN apk add ripgrep\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
"FROM python:3.13\nRUN apk add ripgrep\n",
|
||||
bottle_state.per_bottle_dockerfile("dev"),
|
||||
)
|
||||
|
||||
def test_isolated_per_slug(self):
|
||||
bottle_state.write_per_bottle_dockerfile("dev", "FROM dev\n")
|
||||
bottle_state.write_per_bottle_dockerfile("api", "FROM api\n")
|
||||
self.assertEqual("FROM dev\n", bottle_state.per_bottle_dockerfile("dev"))
|
||||
self.assertEqual("FROM api\n", bottle_state.per_bottle_dockerfile("api"))
|
||||
|
||||
def test_dockerfile_path_under_state_dir(self):
|
||||
path = bottle_state.per_bottle_dockerfile_path("dev")
|
||||
self.assertTrue(str(path).endswith("/.claude-bottle/state/dev/Dockerfile"))
|
||||
|
||||
def test_image_tag_unique_per_slug(self):
|
||||
self.assertEqual(
|
||||
"claude-bottle-rebuilt-dev:latest",
|
||||
bottle_state.per_bottle_image_tag("dev"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
bottle_state.per_bottle_image_tag("dev"),
|
||||
bottle_state.per_bottle_image_tag("api"),
|
||||
)
|
||||
|
||||
def test_transcript_dir_under_state_dir(self):
|
||||
path = bottle_state.transcript_snapshot_dir("dev")
|
||||
self.assertTrue(str(path).endswith("/.claude-bottle/state/dev/transcript"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user