Merge pull request 'refactor(state): write prepare-time scratch files under state/<slug>/' (#34) from chunk-2-state-bind-mount into main
This commit was merged in pull request #34.
This commit is contained in:
@@ -44,6 +44,15 @@ from . import util as docker_mod
|
|||||||
_STATE_SUBDIR = "state"
|
_STATE_SUBDIR = "state"
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
|
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||||
|
# live here so chunk 3's `docker compose up` can find them at stable
|
||||||
|
# paths. Each sidecar's `prepare()` writes config + CAs into its own
|
||||||
|
# subdir; the launch step is unchanged today (still `docker cp`).
|
||||||
|
_PIPELOCK_SUBDIR = "pipelock"
|
||||||
|
_EGRESS_SUBDIR = "egress"
|
||||||
|
_GIT_GATE_SUBDIR = "git-gate"
|
||||||
|
_SUPERVISE_SUBDIR = "supervise"
|
||||||
|
_AGENT_SUBDIR = "agent"
|
||||||
_METADATA_NAME = "metadata.json"
|
_METADATA_NAME = "metadata.json"
|
||||||
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
||||||
# Host's apply paths keep these files fresh so supervise's
|
# Host's apply paths keep these files fresh so supervise's
|
||||||
@@ -201,6 +210,49 @@ def transcript_snapshot_dir(identity: str) -> Path:
|
|||||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
|
||||||
|
#
|
||||||
|
# Each sidecar gets its own subdir under the bottle's state dir for
|
||||||
|
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
|
||||||
|
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
|
||||||
|
# them along with everything else when the bottle session ends and
|
||||||
|
# nothing requested preservation.
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_state_dir(identity: str) -> Path:
|
||||||
|
"""State subdir for the pipelock sidecar: pipelock.yaml + the
|
||||||
|
per-bottle CA cert/key. Bind-mount source from chunk 3 onward."""
|
||||||
|
return bottle_state_dir(identity) / _PIPELOCK_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def egress_state_dir(identity: str) -> Path:
|
||||||
|
"""State subdir for the egress sidecar: routes.yaml + the
|
||||||
|
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
|
||||||
|
return bottle_state_dir(identity) / _EGRESS_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_state_dir(identity: str) -> Path:
|
||||||
|
"""State subdir for the git-gate sidecar: entrypoint + hooks +
|
||||||
|
per-upstream known_hosts. Bind-mount source from chunk 3
|
||||||
|
onward."""
|
||||||
|
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
|
"""State subdir for the supervise sidecar's current-config dir
|
||||||
|
(bind-mounted into the agent at /etc/claude-bottle/current-config).
|
||||||
|
The queue dir is intentionally NOT under here — it lives at
|
||||||
|
~/.claude-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||||
|
survives state-dir cleanup."""
|
||||||
|
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def agent_state_dir(identity: str) -> Path:
|
||||||
|
"""State subdir for the agent's prepare-time scratch files: the
|
||||||
|
env file (docker --env-file source) and the prompt file."""
|
||||||
|
return bottle_state_dir(identity) / _AGENT_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
# --- Preserve-on-close marker ----------------------------------------------
|
# --- Preserve-on-close marker ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -245,18 +297,23 @@ def cleanup_state(identity: str) -> None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BottleMetadata",
|
"BottleMetadata",
|
||||||
|
"agent_state_dir",
|
||||||
"bottle_identity",
|
"bottle_identity",
|
||||||
"bottle_state_dir",
|
"bottle_state_dir",
|
||||||
"cleanup_state",
|
"cleanup_state",
|
||||||
"clear_preserve_marker",
|
"clear_preserve_marker",
|
||||||
|
"egress_state_dir",
|
||||||
|
"git_gate_state_dir",
|
||||||
"is_preserved",
|
"is_preserved",
|
||||||
"mark_preserved",
|
"mark_preserved",
|
||||||
"metadata_path",
|
"metadata_path",
|
||||||
"per_bottle_dockerfile",
|
"per_bottle_dockerfile",
|
||||||
"per_bottle_dockerfile_path",
|
"per_bottle_dockerfile_path",
|
||||||
"per_bottle_image_tag",
|
"per_bottle_image_tag",
|
||||||
|
"pipelock_state_dir",
|
||||||
"preserve_marker_path",
|
"preserve_marker_path",
|
||||||
"read_metadata",
|
"read_metadata",
|
||||||
|
"supervise_state_dir",
|
||||||
"transcript_snapshot_dir",
|
"transcript_snapshot_dir",
|
||||||
"write_metadata",
|
"write_metadata",
|
||||||
"write_per_bottle_dockerfile",
|
"write_per_bottle_dockerfile",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from . import network as network_mod
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .bottle_state import egress_state_dir, pipelock_state_dir
|
||||||
from .egress import (
|
from .egress import (
|
||||||
DockerEgress,
|
DockerEgress,
|
||||||
egress_tls_init,
|
egress_tls_init,
|
||||||
@@ -105,9 +106,13 @@ def launch(
|
|||||||
# the .start steps docker-cp the files in. Private keys never
|
# the .start steps docker-cp the files in. Private keys never
|
||||||
# leave the host stage dir, which start.py's outer finally
|
# leave the host stage dir, which start.py's outer finally
|
||||||
# `shutil.rmtree`s after the sidecars are torn down.
|
# `shutil.rmtree`s after the sidecars are torn down.
|
||||||
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
|
# PRD 0018 chunk 2: CAs live under the bottle's state subdirs
|
||||||
|
# so chunk 3's compose bind-mounts have stable sources. The
|
||||||
|
# subdirs were created by prepare; tls_init makes the
|
||||||
|
# `pipelock-ca/` and `egress-ca/` children under them.
|
||||||
|
ca_cert_host, ca_key_host = pipelock_tls_init(pipelock_state_dir(plan.slug))
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
||||||
plan.stage_dir,
|
egress_state_dir(plan.slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re-render the pipelock yaml with the SSRF allowlist now that
|
# Re-render the pipelock yaml with the SSRF allowlist now that
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ from .egress import DockerEgress, egress_container_name
|
|||||||
from .git_gate import DockerGitGate, git_gate_container_name
|
from .git_gate import DockerGitGate, git_gate_container_name
|
||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
|
agent_state_dir,
|
||||||
bottle_identity,
|
bottle_identity,
|
||||||
clear_preserve_marker,
|
clear_preserve_marker,
|
||||||
|
egress_state_dir,
|
||||||
|
git_gate_state_dir,
|
||||||
per_bottle_dockerfile,
|
per_bottle_dockerfile,
|
||||||
per_bottle_dockerfile_path,
|
per_bottle_dockerfile_path,
|
||||||
per_bottle_image_tag,
|
per_bottle_image_tag,
|
||||||
|
pipelock_state_dir,
|
||||||
|
supervise_state_dir,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
||||||
@@ -141,14 +146,30 @@ def resolve_plan(
|
|||||||
f"retry."
|
f"retry."
|
||||||
)
|
)
|
||||||
|
|
||||||
env_file = stage_dir / "agent.env"
|
# PRD 0018 chunk 2: prepare-time scratch files live under
|
||||||
prompt_file = stage_dir / "prompt.txt"
|
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
|
||||||
|
# bind-mounts can point at stable paths. The state subdirs are
|
||||||
|
# cleaned up by start.py's session-end teardown unless something
|
||||||
|
# explicitly preserves the state dir (capability-block, crash).
|
||||||
|
agent_dir = agent_state_dir(slug)
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
env_file = agent_dir / "agent.env"
|
||||||
|
prompt_file = agent_dir / "prompt.txt"
|
||||||
prompt_file.write_text("")
|
prompt_file.write_text("")
|
||||||
prompt_file.chmod(0o600)
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
pipelock_dir = pipelock_state_dir(slug)
|
||||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
pipelock_dir.mkdir(parents=True, exist_ok=True)
|
||||||
egress_plan = egress.prepare(bottle, slug, stage_dir)
|
proxy_plan = proxy.prepare(bottle, slug, pipelock_dir)
|
||||||
|
|
||||||
|
git_gate_dir = git_gate_state_dir(slug)
|
||||||
|
git_gate_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir)
|
||||||
|
|
||||||
|
egress_dir = egress_state_dir(slug)
|
||||||
|
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
egress_plan = egress.prepare(bottle, slug, egress_dir)
|
||||||
|
|
||||||
supervise_plan = None
|
supervise_plan = None
|
||||||
if bottle.supervise:
|
if bottle.supervise:
|
||||||
# Current Dockerfile for the agent image. Read from the repo
|
# Current Dockerfile for the agent image. Read from the repo
|
||||||
@@ -161,8 +182,10 @@ def resolve_plan(
|
|||||||
# state rather than a launch-time snapshot.)
|
# state rather than a launch-time snapshot.)
|
||||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||||
|
supervise_dir = supervise_state_dir(slug)
|
||||||
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||||
supervise_plan = supervise.prepare(
|
supervise_plan = supervise.prepare(
|
||||||
slug, stage_dir,
|
slug, supervise_dir,
|
||||||
dockerfile_content=dockerfile_content,
|
dockerfile_content=dockerfile_content,
|
||||||
)
|
)
|
||||||
resolved = resolve_env(manifest, spec.agent_name)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ def _launch_bottle(
|
|||||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
||||||
attaches claude, and prints the resume hint on session end."""
|
attaches claude, and prints the resume hint on session end."""
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
||||||
|
identity = ""
|
||||||
try:
|
try:
|
||||||
backend = get_bottle_backend()
|
backend = get_bottle_backend()
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
|
identity = _identity_from_plan(plan)
|
||||||
|
|
||||||
plan.print(remote_control=remote_control)
|
plan.print(remote_control=remote_control)
|
||||||
|
|
||||||
@@ -78,7 +80,6 @@ def _launch_bottle(
|
|||||||
info("aborted by user")
|
info("aborted by user")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
identity = _identity_from_plan(plan)
|
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
info(
|
info(
|
||||||
"attaching interactive claude session "
|
"attaching interactive claude session "
|
||||||
@@ -101,14 +102,15 @@ def _launch_bottle(
|
|||||||
# capability-block path's prior snapshot isn't clobbered
|
# capability-block path's prior snapshot isn't clobbered
|
||||||
# when the container is already gone.
|
# when the container is already gone.
|
||||||
_capture_session_state(identity, exit_code)
|
_capture_session_state(identity, exit_code)
|
||||||
# Context exited → containers + networks gone. Now decide
|
|
||||||
# what to do with the per-bottle state dir on the host: any
|
|
||||||
# preserve marker (capability-block OR crash) keeps it; a
|
|
||||||
# clean exit cleans it up so ~/.claude-bottle/state/ doesn't
|
|
||||||
# accumulate per-launch debris.
|
|
||||||
_settle_state(identity)
|
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
|
# sources under state/<slug>/. If we never reached the
|
||||||
|
# launch context (dry-run, preflight-N, prepare exception), or
|
||||||
|
# we did but nothing requested preservation, reap them along
|
||||||
|
# with everything else. _settle_state subsumes the prior
|
||||||
|
# post-launch settlement and the new pre-launch cleanup.
|
||||||
|
_settle_state(identity)
|
||||||
shutil.rmtree(stage_dir, ignore_errors=True)
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user