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
|
image: str
|
||||||
derived_image: str # "" -> no derived image
|
derived_image: str # "" -> no derived image
|
||||||
runtime_image: str # image actually launched (derived or base)
|
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
|
env_file: Path # docker --env-file: NAME=VALUE literals
|
||||||
# name -> value for vars forwarded into the docker-run child process
|
# name -> value for vars forwarded into the docker-run child process
|
||||||
# via subprocess env (so values never land on argv or in a file).
|
# via subprocess env (so values never land on argv or in a file).
|
||||||
@@ -89,6 +94,11 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(f"agent : {spec.agent_name}")
|
info(f"agent : {spec.agent_name}")
|
||||||
info(f"image : {self.image}")
|
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:
|
if self.derived_image:
|
||||||
info(
|
info(
|
||||||
f"cwd : {spec.user_cwd} -> /home/node/workspace "
|
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
|
pass
|
||||||
|
|
||||||
try:
|
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:
|
if plan.derived_image:
|
||||||
docker_mod.build_image_with_cwd(
|
docker_mod.build_image_with_cwd(
|
||||||
plan.derived_image, plan.image, plan.spec.user_cwd
|
plan.derived_image, plan.image, plan.spec.user_cwd
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ from .cred_proxy import (
|
|||||||
cred_proxy_url,
|
cred_proxy_url,
|
||||||
)
|
)
|
||||||
from .git_gate import DockerGitGate, git_gate_container_name
|
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 .pipelock import DockerPipelockProxy, pipelock_container_name
|
||||||
from .supervise import DockerSupervise, supervise_container_name
|
from .supervise import DockerSupervise, supervise_container_name
|
||||||
|
|
||||||
@@ -50,7 +55,17 @@ def resolve_plan(
|
|||||||
|
|
||||||
slug = docker_mod.slugify(spec.agent_name)
|
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 = ""
|
derived_image = ""
|
||||||
runtime_image = image
|
runtime_image = image
|
||||||
if spec.copy_cwd:
|
if spec.copy_cwd:
|
||||||
@@ -184,6 +199,7 @@ def resolve_plan(
|
|||||||
image=image,
|
image=image,
|
||||||
derived_image=derived_image,
|
derived_image=derived_image,
|
||||||
runtime_image=runtime_image,
|
runtime_image=runtime_image,
|
||||||
|
dockerfile_path=dockerfile_path,
|
||||||
env_file=env_file,
|
env_file=env_file,
|
||||||
forwarded_env=forwarded_env,
|
forwarded_env=forwarded_env,
|
||||||
prompt_file=prompt_file,
|
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