refactor(bottles): make docker a package; absorb top-level docker.py
test / run tests/run_tests.py (pull_request) Successful in 14s
test / run tests/run_tests.py (pull_request) Successful in 14s
- claude_bottle/bottles/docker.py -> claude_bottle/bottles/docker/__init__.py - claude_bottle/docker.py -> claude_bottle/bottles/docker/util.py The host-side Docker primitives (require_docker, slugify, image_exists, container_exists, build_image, build_image_with_cwd) lived at the top of the package as if cross-cutting, but they are Docker-specific. Moving them into bottles/docker/util.py colocates them with the platform that uses them and clears the top-level namespace. Imports in bottles/docker/__init__.py shift to `from . import util as docker_mod` so the existing call sites (`docker_mod.require_docker()`, etc.) keep working unchanged.
This commit is contained in:
@@ -27,18 +27,18 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from .. import docker as docker_mod
|
||||
from .. import network as network_mod
|
||||
from .. import pipelock
|
||||
from .. import skills as skills_mod
|
||||
from .. import ssh as ssh_mod
|
||||
from ..env_resolve import env_resolve
|
||||
from ..log import die, info
|
||||
from . import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec
|
||||
from ... import network as network_mod
|
||||
from ... import pipelock
|
||||
from ... import skills as skills_mod
|
||||
from ... import ssh as ssh_mod
|
||||
from ...env_resolve import env_resolve
|
||||
from ...log import die, info
|
||||
from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
# --- Runtime detection -----------------------------------------------------
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Docker host-side primitives used by DockerBottlePlatform: probing
|
||||
for docker on PATH, slugifying agent names, checking image/container
|
||||
existence, and building images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
def require_docker() -> None:
|
||||
"""Fail with an install pointer if `docker` is not on PATH."""
|
||||
if shutil.which("docker") is None:
|
||||
info("Docker is required but was not found on PATH.")
|
||||
info("macOS: install Docker Desktop https://docs.docker.com/desktop/install/mac-install/")
|
||||
info("Linux: install Docker Engine https://docs.docker.com/engine/install/")
|
||||
die("docker not found")
|
||||
|
||||
|
||||
def image_exists(ref: str) -> bool:
|
||||
return _silent_run(["docker", "image", "inspect", ref]) == 0
|
||||
|
||||
|
||||
def container_exists(name: str) -> bool:
|
||||
"""Returns True if a container (running or stopped) with the given
|
||||
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
||||
matches don't false-positive."""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
|
||||
if not name:
|
||||
die("slugify: missing name")
|
||||
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||
if not slug:
|
||||
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||
return slug
|
||||
|
||||
|
||||
def build_image(ref: str, context: str) -> None:
|
||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||
rebuilds cheap; running every time means Dockerfile edits land
|
||||
without manual `docker rmi`."""
|
||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||
subprocess.run(["docker", "build", "-t", ref, context], check=True)
|
||||
|
||||
|
||||
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||
'const fs=require("fs"),p=process.env.HOME+"/.claude.json",'
|
||||
'c=JSON.parse(fs.readFileSync(p,"utf8"));'
|
||||
'c.projects=c.projects||{};'
|
||||
'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};'
|
||||
'fs.writeFileSync(p,JSON.stringify(c,null,2));'
|
||||
)
|
||||
|
||||
|
||||
def build_image_with_cwd(derived: str, base: str, cwd: str) -> None:
|
||||
"""Build a thin derived image that copies <cwd> into
|
||||
/home/node/workspace and adds a trust-dialog entry for it."""
|
||||
import os
|
||||
|
||||
if not os.path.isdir(cwd):
|
||||
die(f"cwd not found at {cwd}")
|
||||
info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
|
||||
dockerfile = (
|
||||
f"FROM {base}\n"
|
||||
f"COPY --chown=node:node . /home/node/workspace\n"
|
||||
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
|
||||
f"WORKDIR /home/node/workspace\n"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", derived, "-f", "-", cwd],
|
||||
input=dockerfile,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _silent_run(cmd: Iterable[str]) -> int:
|
||||
return subprocess.run(
|
||||
list(cmd),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode
|
||||
Reference in New Issue
Block a user