agent-containers 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_containers/__init__.py +1 -0
- agent_containers/__main__.py +6 -0
- agent_containers/_assets/agent-images/adal/Dockerfile +103 -0
- agent_containers/_assets/agent-images/adal/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/adal/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/adal/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/adal/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/adal/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/agent-gateway/Dockerfile +50 -0
- agent_containers/_assets/agent-images/agent-gateway/entrypoint.sh +28 -0
- agent_containers/_assets/agent-images/agent-gateway/sshd_config.agent-gateway +11 -0
- agent_containers/_assets/agent-images/aider/Dockerfile +114 -0
- agent_containers/_assets/agent-images/aider/examples/egress-allowlist.txt +16 -0
- agent_containers/_assets/agent-images/aider/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/aider/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/aider/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/aider/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/claude-code/Dockerfile +171 -0
- agent_containers/_assets/agent-images/claude-code/entrypoint.sh +22 -0
- agent_containers/_assets/agent-images/claude-code/examples/egress-allowlist.txt +14 -0
- agent_containers/_assets/agent-images/claude-code/examples/settings.local-model.json +7 -0
- agent_containers/_assets/agent-images/claude-code/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/claude-code/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/claude-code/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/claude-code/plugin-marketplaces.txt +23 -0
- agent_containers/_assets/agent-images/claude-code/plugins.txt +13 -0
- agent_containers/_assets/agent-images/claude-code/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/claude-code/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/codex/Dockerfile +147 -0
- agent_containers/_assets/agent-images/codex/codex-wrapper.sh +13 -0
- agent_containers/_assets/agent-images/codex/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/codex/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/codex/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/codex/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/codex/plugin-marketplaces.txt +2 -0
- agent_containers/_assets/agent-images/codex/plugins.txt +2 -0
- agent_containers/_assets/agent-images/codex/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/codex/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/hermes/Dockerfile +122 -0
- agent_containers/_assets/agent-images/hermes/entrypoint.sh +49 -0
- agent_containers/_assets/agent-images/hermes/packages-apt.txt +16 -0
- agent_containers/_assets/agent-images/hermes/packages-npm.txt +7 -0
- agent_containers/_assets/agent-images/hermes/packages-uv.txt +9 -0
- agent_containers/_assets/agent-images/hermes/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/hermes/tools-uv.txt +9 -0
- agent_containers/_assets/agent-images/kilo-code/Dockerfile +101 -0
- agent_containers/_assets/agent-images/kilo-code/examples/egress-allowlist.txt +7 -0
- agent_containers/_assets/agent-images/kilo-code/packages-apt.txt +5 -0
- agent_containers/_assets/agent-images/kilo-code/packages-npm.txt +1 -0
- agent_containers/_assets/agent-images/kilo-code/packages-uv.txt +4 -0
- agent_containers/_assets/agent-images/kilo-code/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/opencode/Dockerfile +126 -0
- agent_containers/_assets/agent-images/opencode/examples/egress-allowlist.txt +6 -0
- agent_containers/_assets/agent-images/opencode/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/opencode/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/opencode/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/opencode/proxy-ca.d/.keep +1 -0
- agent_containers/_assets/agent-images/opencode/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/qwen-code/Dockerfile +104 -0
- agent_containers/_assets/agent-images/qwen-code/examples/egress-allowlist.txt +10 -0
- agent_containers/_assets/agent-images/qwen-code/packages-apt.txt +13 -0
- agent_containers/_assets/agent-images/qwen-code/packages-npm.txt +2 -0
- agent_containers/_assets/agent-images/qwen-code/packages-uv.txt +6 -0
- agent_containers/_assets/agent-images/qwen-code/tools-uv.txt +4 -0
- agent_containers/_assets/agent-images/shared/egress-allowlist.sh +138 -0
- agent_containers/_assets/agent-images/shared/install-additional-packages.sh +79 -0
- agent_containers/_assets/agent-images/shared/install-workload-base.sh +46 -0
- agent_containers/_assets/agent-images/shared/workload-entrypoint.sh +82 -0
- agent_containers/_assets/agent-images/volume-bridge/Dockerfile +37 -0
- agent_containers/_assets/agent-images/volume-bridge/entrypoint.sh +29 -0
- agent_containers/_assets/agent-images/volume-bridge/serve.sh +66 -0
- agent_containers/assets.py +38 -0
- agent_containers/build_context.py +116 -0
- agent_containers/cli.py +212 -0
- agent_containers/creation.py +177 -0
- agent_containers/docker.py +562 -0
- agent_containers/lifecycle.py +325 -0
- agent_containers/planner.py +99 -0
- agent_containers/profile.py +377 -0
- agent_containers/shortcuts.py +159 -0
- agent_containers/state.py +154 -0
- agent_containers-0.1.0.dev0.dist-info/METADATA +195 -0
- agent_containers-0.1.0.dev0.dist-info/RECORD +86 -0
- agent_containers-0.1.0.dev0.dist-info/WHEEL +4 -0
- agent_containers-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- agent_containers-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Failure-safe Docker application of a desired profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agent_containers.build_context import prepare_build_contexts
|
|
14
|
+
from agent_containers.docker import (
|
|
15
|
+
build_image_argv,
|
|
16
|
+
build_seed_argv,
|
|
17
|
+
default_home_volume,
|
|
18
|
+
default_image_tag,
|
|
19
|
+
legacy_home_volume,
|
|
20
|
+
)
|
|
21
|
+
from agent_containers.planner import PlanAction, build_plan
|
|
22
|
+
from agent_containers.profile import MountType, Profile
|
|
23
|
+
from agent_containers.shortcuts import render_decant_shortcut, render_shortcut, update_shortcuts
|
|
24
|
+
from agent_containers.state import (
|
|
25
|
+
DeploymentRecord,
|
|
26
|
+
DeploymentState,
|
|
27
|
+
default_state_path,
|
|
28
|
+
default_state_path_for_name,
|
|
29
|
+
load_state,
|
|
30
|
+
new_deployment_id,
|
|
31
|
+
profile_digest,
|
|
32
|
+
profile_snapshot,
|
|
33
|
+
save_state,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_DOCTOR_HOME_PATHS = {
|
|
37
|
+
"claude-code": "/home/claude",
|
|
38
|
+
"opencode": "/home/opencode",
|
|
39
|
+
"codex": "/home/codex",
|
|
40
|
+
"hermes": "/opt/data",
|
|
41
|
+
}
|
|
42
|
+
_TOOL_SHADOW_SCRIPT = """set -eu
|
|
43
|
+
list_tools() {
|
|
44
|
+
kind="$1"
|
|
45
|
+
directory="$2"
|
|
46
|
+
[ -d "$directory" ] || return 0
|
|
47
|
+
for path in "$directory"/*; do
|
|
48
|
+
[ -f "$path" ] || [ -L "$path" ] || continue
|
|
49
|
+
printf '%s\\t%s\\n' "$kind" "${path##*/}"
|
|
50
|
+
done
|
|
51
|
+
}
|
|
52
|
+
list_tools home "$HOME/.local/bin"
|
|
53
|
+
list_tools home "$HOME/.npm-global/bin"
|
|
54
|
+
list_tools managed /opt/agent-tools/bin
|
|
55
|
+
list_tools managed /opt/agent-tools/npm/bin
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LifecycleError(ValueError):
|
|
60
|
+
"""Raised when a state file is not safe to apply for a profile."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class DoctorReport:
|
|
65
|
+
"""Read-only host and selected-deployment diagnostics."""
|
|
66
|
+
|
|
67
|
+
lines: tuple[str, ...]
|
|
68
|
+
healthy: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_user_ids() -> tuple[int, int]:
|
|
72
|
+
"""Return safe image IDs while preserving Dockerfile collision rejection."""
|
|
73
|
+
# macOS's conventional staff GID (20) is already assigned in Debian-based
|
|
74
|
+
# images. Keep the documented groupmod rejection; Docker Desktop builds use
|
|
75
|
+
# the baked-in safe GID while retaining the host UID for bind-mount ownership.
|
|
76
|
+
return os.getuid(), 1000 if sys.platform == "darwin" else os.getgid()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_profile(
|
|
80
|
+
profile: Profile,
|
|
81
|
+
profile_path: Path,
|
|
82
|
+
state_path: Path | None = None,
|
|
83
|
+
shortcuts_path: Path | None = None,
|
|
84
|
+
) -> DeploymentRecord:
|
|
85
|
+
"""Build or verify a deployment, seed it safely, then atomically select it."""
|
|
86
|
+
target_state_path = state_path or default_state_path(profile)
|
|
87
|
+
state = load_state(target_state_path) if target_state_path.exists() else DeploymentState(profile_name=profile.name)
|
|
88
|
+
if state.profile_name != profile.name:
|
|
89
|
+
raise LifecycleError(f"state belongs to profile {state.profile_name!r}, not {profile.name!r}")
|
|
90
|
+
plan = build_plan(profile, state, profile_path)
|
|
91
|
+
selected = state.selected_deployment
|
|
92
|
+
image = selected.image if selected is not None else default_image_tag(profile)
|
|
93
|
+
if PlanAction.CREATE_IMAGE in plan.actions or PlanAction.UPDATE_IMAGE in plan.actions:
|
|
94
|
+
image = _build_image(profile, profile_path)
|
|
95
|
+
else:
|
|
96
|
+
_docker("docker", "image", "inspect", image)
|
|
97
|
+
if not plan.is_noop:
|
|
98
|
+
record = _new_record(profile, image, profile_path)
|
|
99
|
+
if shortcuts_path is not None:
|
|
100
|
+
# Validate the complete launch before seeds or state can make this
|
|
101
|
+
# deployment visible. The later atomic write still refreshes the
|
|
102
|
+
# generated file after state selection succeeds.
|
|
103
|
+
render_shortcut(profile, record, profile_path)
|
|
104
|
+
decant_sources = _resolve_decant_sources(profile, record, target_state_path)
|
|
105
|
+
if profile.decant.enabled:
|
|
106
|
+
render_decant_shortcut(profile, decant_sources)
|
|
107
|
+
else:
|
|
108
|
+
decant_sources = None
|
|
109
|
+
_apply_seeds(profile, profile_path, image)
|
|
110
|
+
state.deployments = [item.model_copy(update={"selected": False}) for item in state.deployments]
|
|
111
|
+
state.deployments.append(record)
|
|
112
|
+
save_state(target_state_path, state)
|
|
113
|
+
if shortcuts_path is not None:
|
|
114
|
+
update_shortcuts(shortcuts_path, profile, record, profile_path, decant_sources)
|
|
115
|
+
return record
|
|
116
|
+
assert selected is not None
|
|
117
|
+
if shortcuts_path is not None:
|
|
118
|
+
decant_sources = _resolve_decant_sources(profile, selected, target_state_path)
|
|
119
|
+
if profile.decant.enabled:
|
|
120
|
+
render_decant_shortcut(profile, decant_sources)
|
|
121
|
+
update_shortcuts(shortcuts_path, profile, selected, profile_path, decant_sources)
|
|
122
|
+
return selected
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rollback_profile(
|
|
126
|
+
profile: Profile,
|
|
127
|
+
state_path: Path | None = None,
|
|
128
|
+
shortcuts_path: Path | None = None,
|
|
129
|
+
profile_path: Path | None = None,
|
|
130
|
+
) -> DeploymentRecord:
|
|
131
|
+
"""Select the immediately preceding retained image after verifying it exists."""
|
|
132
|
+
target_state_path = state_path or default_state_path(profile)
|
|
133
|
+
state = load_state(target_state_path)
|
|
134
|
+
if state.profile_name != profile.name:
|
|
135
|
+
raise LifecycleError(f"state belongs to profile {state.profile_name!r}, not {profile.name!r}")
|
|
136
|
+
selected_index = next((index for index, item in enumerate(state.deployments) if item.selected), None)
|
|
137
|
+
if selected_index is None:
|
|
138
|
+
raise LifecycleError("rollback requires a selected deployment")
|
|
139
|
+
if selected_index == 0:
|
|
140
|
+
raise LifecycleError("no prior deployment is retained for rollback")
|
|
141
|
+
target = state.deployments[selected_index - 1]
|
|
142
|
+
_docker("docker", "image", "inspect", target.image)
|
|
143
|
+
restored_profile = Profile.model_validate(target.profile_snapshot)
|
|
144
|
+
shortcut_profile_path = profile_path or Path.cwd() / "profile.toml"
|
|
145
|
+
if shortcuts_path is not None:
|
|
146
|
+
# A historical profile may reference an input that no longer exists;
|
|
147
|
+
# refuse before changing state so rollback cannot strand the shortcut.
|
|
148
|
+
render_shortcut(restored_profile, target, shortcut_profile_path)
|
|
149
|
+
decant_sources = _resolve_decant_sources(restored_profile, target, target_state_path)
|
|
150
|
+
if restored_profile.decant.enabled:
|
|
151
|
+
render_decant_shortcut(restored_profile, decant_sources)
|
|
152
|
+
else:
|
|
153
|
+
decant_sources = None
|
|
154
|
+
state.deployments = [
|
|
155
|
+
item.model_copy(update={"selected": item.deployment_id == target.deployment_id}) for item in state.deployments
|
|
156
|
+
]
|
|
157
|
+
save_state(target_state_path, state)
|
|
158
|
+
if shortcuts_path is not None:
|
|
159
|
+
update_shortcuts(shortcuts_path, restored_profile, target, shortcut_profile_path, decant_sources)
|
|
160
|
+
return state.selected_deployment or target
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def rollback_preview(record: DeploymentRecord) -> list[str]:
|
|
164
|
+
"""Render the managed launch choices restored by a rollback without secrets."""
|
|
165
|
+
snapshot = record.profile_snapshot
|
|
166
|
+
egress = snapshot.get("egress", {})
|
|
167
|
+
proxy = snapshot.get("proxy")
|
|
168
|
+
provider = snapshot.get("provider")
|
|
169
|
+
return [
|
|
170
|
+
f"Rollback target: {record.deployment_id} ({record.image})",
|
|
171
|
+
f"Restored egress mode: {egress.get('mode', 'unknown')}",
|
|
172
|
+
f"Restored egress hosts: {', '.join(egress.get('hosts', [])) or '(none)'}",
|
|
173
|
+
f"Restored proxy/CA: {'configured' if proxy else 'none'}",
|
|
174
|
+
f"Restored provider endpoint: {'configured' if provider and provider.get('endpoint') else 'none'}",
|
|
175
|
+
"Warning: persistent home-volume data is not rolled back.",
|
|
176
|
+
"Warning: existing agent sessions are not stopped.",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def doctor_profile(profile: Profile, state_path: Path | None = None) -> DoctorReport:
|
|
181
|
+
"""Inspect local Docker/state prerequisites without building or mutating resources."""
|
|
182
|
+
lines: list[str] = []
|
|
183
|
+
docker_available = _probe("docker", "version", "--format", "{{.Server.Version}}")
|
|
184
|
+
lines.append(f"Docker daemon: {'available' if docker_available else 'unavailable'}")
|
|
185
|
+
target_state_path = state_path or default_state_path(profile)
|
|
186
|
+
if not target_state_path.exists():
|
|
187
|
+
lines.append(f"Deployment state: absent ({target_state_path})")
|
|
188
|
+
return DoctorReport(tuple(lines), healthy=False)
|
|
189
|
+
state = load_state(target_state_path)
|
|
190
|
+
if state.profile_name != profile.name:
|
|
191
|
+
raise LifecycleError(f"state belongs to profile {state.profile_name!r}, not {profile.name!r}")
|
|
192
|
+
selected = state.selected_deployment
|
|
193
|
+
if selected is None:
|
|
194
|
+
lines.append("Selected deployment: absent")
|
|
195
|
+
return DoctorReport(tuple(lines), healthy=False)
|
|
196
|
+
image_available = docker_available and _probe("docker", "image", "inspect", selected.image)
|
|
197
|
+
home_volume = selected.home_volume or legacy_home_volume(profile)
|
|
198
|
+
volume_available = docker_available and _probe("docker", "volume", "inspect", home_volume)
|
|
199
|
+
lines.append(f"Selected deployment: {selected.deployment_id} ({selected.image})")
|
|
200
|
+
lines.append(f"Selected image: {'available' if image_available else 'unavailable'}")
|
|
201
|
+
lines.append(f"Home volume: {home_volume} ({'present' if volume_available else 'not created yet'})")
|
|
202
|
+
if not volume_available:
|
|
203
|
+
lines.append("Tool shadowing: skipped (home volume is not available)")
|
|
204
|
+
else:
|
|
205
|
+
shadows = _inspect_tool_shadows(selected.image, home_volume, profile.agent.value)
|
|
206
|
+
if shadows is None:
|
|
207
|
+
lines.append("Tool shadowing: unavailable (could not inspect the home volume)")
|
|
208
|
+
elif shadows:
|
|
209
|
+
lines.append(f"Tool shadowing: {', '.join(shadows)}")
|
|
210
|
+
else:
|
|
211
|
+
lines.append("Tool shadowing: none detected")
|
|
212
|
+
return DoctorReport(tuple(lines), healthy=docker_available and image_available)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _inspect_tool_shadows(image: str, home_volume: str, agent: str) -> tuple[str, ...] | None:
|
|
216
|
+
"""Compare writable home tool names with image-managed tool names."""
|
|
217
|
+
home_path = _DOCTOR_HOME_PATHS.get(agent)
|
|
218
|
+
if home_path is None:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
[
|
|
223
|
+
"docker",
|
|
224
|
+
"run",
|
|
225
|
+
"--rm",
|
|
226
|
+
"--network=none",
|
|
227
|
+
"--read-only",
|
|
228
|
+
"--cap-drop=ALL",
|
|
229
|
+
"--mount",
|
|
230
|
+
f"type=volume,src={home_volume},dst={home_path},readonly",
|
|
231
|
+
"--entrypoint",
|
|
232
|
+
"/bin/sh",
|
|
233
|
+
image,
|
|
234
|
+
"-ceu",
|
|
235
|
+
_TOOL_SHADOW_SCRIPT,
|
|
236
|
+
],
|
|
237
|
+
check=False,
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True,
|
|
240
|
+
)
|
|
241
|
+
except OSError:
|
|
242
|
+
return None
|
|
243
|
+
if result.returncode != 0:
|
|
244
|
+
return None
|
|
245
|
+
home_tools: set[str] = set()
|
|
246
|
+
managed_tools: set[str] = set()
|
|
247
|
+
for line in result.stdout.splitlines():
|
|
248
|
+
kind, separator, name = line.partition("\t")
|
|
249
|
+
if not separator or not name:
|
|
250
|
+
continue
|
|
251
|
+
if kind == "home":
|
|
252
|
+
home_tools.add(name)
|
|
253
|
+
elif kind == "managed":
|
|
254
|
+
managed_tools.add(name)
|
|
255
|
+
return tuple(sorted(home_tools & managed_tools))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _build_image(profile: Profile, profile_path: Path) -> str:
|
|
259
|
+
"""Build a profile context in an automatically removed private directory."""
|
|
260
|
+
image = default_image_tag(profile, profile_path)
|
|
261
|
+
with tempfile.TemporaryDirectory(prefix="agent-containers-") as temporary:
|
|
262
|
+
contexts = prepare_build_contexts(profile, Path(temporary) / "context", profile_path)
|
|
263
|
+
uid, gid = build_user_ids()
|
|
264
|
+
_docker(*build_image_argv(profile, contexts, image=image, uid=uid, gid=gid))
|
|
265
|
+
return image
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve_decant_sources(
|
|
269
|
+
profile: Profile,
|
|
270
|
+
current_record: DeploymentRecord,
|
|
271
|
+
current_state_path: Path,
|
|
272
|
+
) -> dict[str, DeploymentRecord]:
|
|
273
|
+
"""Load selected source deployments needed by an opted-in Decant shortcut."""
|
|
274
|
+
if not profile.decant.enabled:
|
|
275
|
+
return {}
|
|
276
|
+
sources: dict[str, DeploymentRecord] = {}
|
|
277
|
+
for source_name in profile.decant.source_profiles:
|
|
278
|
+
source_path = current_state_path if source_name == profile.name else default_state_path_for_name(source_name)
|
|
279
|
+
if source_name == profile.name:
|
|
280
|
+
source_record = current_record
|
|
281
|
+
elif not source_path.exists():
|
|
282
|
+
raise LifecycleError(f"Decant source profile state is unavailable: {source_name}")
|
|
283
|
+
else:
|
|
284
|
+
source_state = load_state(source_path)
|
|
285
|
+
if source_state.profile_name != source_name:
|
|
286
|
+
raise LifecycleError(
|
|
287
|
+
f"Decant source state belongs to profile {source_state.profile_name!r}, not {source_name!r}"
|
|
288
|
+
)
|
|
289
|
+
source_record = source_state.selected_deployment
|
|
290
|
+
if source_record is None:
|
|
291
|
+
raise LifecycleError(f"Decant source profile has no selected deployment: {source_name}")
|
|
292
|
+
sources[source_name] = source_record
|
|
293
|
+
return sources
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _apply_seeds(profile: Profile, profile_path: Path, image: str) -> None:
|
|
297
|
+
"""Copy new seed inputs only after the image is available for its adapter."""
|
|
298
|
+
for mount in profile.mounts:
|
|
299
|
+
if mount.type == MountType.SEED:
|
|
300
|
+
_docker(*build_seed_argv(profile, mount.target, profile_path, image=image, home_volume=profile.home_volume))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _new_record(profile: Profile, image: str, profile_path: Path | None = None) -> DeploymentRecord:
|
|
304
|
+
"""Create an unambiguous selected deployment record after successful work."""
|
|
305
|
+
digest = profile_digest(profile, profile_path)
|
|
306
|
+
return DeploymentRecord(
|
|
307
|
+
deployment_id=new_deployment_id(profile, profile_path),
|
|
308
|
+
image=image,
|
|
309
|
+
home_volume=profile.home_volume or default_home_volume(profile),
|
|
310
|
+
profile_digest=digest,
|
|
311
|
+
profile_snapshot=profile_snapshot(profile),
|
|
312
|
+
launch_digest=digest,
|
|
313
|
+
created_at=datetime.now(UTC),
|
|
314
|
+
selected=True,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _docker(*argv: str) -> None:
|
|
319
|
+
"""Run Docker without shell interpolation or captured credential-bearing output."""
|
|
320
|
+
subprocess.run(argv, check=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _probe(*argv: str) -> bool:
|
|
324
|
+
"""Return whether a harmless Docker inspection succeeds without raising."""
|
|
325
|
+
return subprocess.run(argv, check=False, capture_output=True, text=True).returncode == 0
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Offline change planning for desired profiles and recorded deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from agent_containers.profile import Profile
|
|
11
|
+
from agent_containers.state import DeploymentState, profile_digest, profile_snapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PlanAction(StrEnum):
|
|
15
|
+
"""Changes the apply operation may need to perform."""
|
|
16
|
+
|
|
17
|
+
CREATE_IMAGE = "create-image"
|
|
18
|
+
UPDATE_IMAGE = "update-image"
|
|
19
|
+
UPDATE_LAUNCH = "update-launch"
|
|
20
|
+
NOOP = "no-op"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Plan(BaseModel):
|
|
24
|
+
"""A deterministic, explicitly offline description of desired changes."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
profile_name: str
|
|
29
|
+
profile_digest: str = Field(min_length=64, max_length=64)
|
|
30
|
+
actions: list[PlanAction]
|
|
31
|
+
changed_sections: list[str] = Field(default_factory=list)
|
|
32
|
+
warnings: list[str] = Field(default_factory=list)
|
|
33
|
+
live_state_checked: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_noop(self) -> bool:
|
|
37
|
+
"""Whether the recorded deployment already matches the profile."""
|
|
38
|
+
return self.actions == [PlanAction.NOOP]
|
|
39
|
+
|
|
40
|
+
def summary_lines(self) -> list[str]:
|
|
41
|
+
"""Render stable, human-readable plan output."""
|
|
42
|
+
lines = [
|
|
43
|
+
f"Profile: {self.profile_name}",
|
|
44
|
+
f"Live Docker state checked: {'yes' if self.live_state_checked else 'no'}",
|
|
45
|
+
]
|
|
46
|
+
if self.actions:
|
|
47
|
+
lines.append("Actions:")
|
|
48
|
+
lines.extend(f"- {action.value}" for action in self.actions)
|
|
49
|
+
if self.changed_sections:
|
|
50
|
+
lines.append("Changed sections:")
|
|
51
|
+
lines.extend(f"- {section}" for section in self.changed_sections)
|
|
52
|
+
if self.warnings:
|
|
53
|
+
lines.append("Warnings:")
|
|
54
|
+
lines.extend(f"- {warning}" for warning in self.warnings)
|
|
55
|
+
return lines
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_plan(profile: Profile, state: DeploymentState | None = None, profile_path: Path | None = None) -> Plan:
|
|
59
|
+
"""Compare a profile with recorded state without contacting Docker."""
|
|
60
|
+
snapshot = profile_snapshot(profile)
|
|
61
|
+
digest = profile_digest(profile, profile_path)
|
|
62
|
+
warnings = ["Live Docker state was not inspected."]
|
|
63
|
+
if state is None or state.selected_deployment is None:
|
|
64
|
+
return Plan(
|
|
65
|
+
profile_name=profile.name,
|
|
66
|
+
profile_digest=digest,
|
|
67
|
+
actions=[PlanAction.CREATE_IMAGE],
|
|
68
|
+
changed_sections=["initial deployment"],
|
|
69
|
+
warnings=warnings + ["No selected deployment is recorded."],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
selected = state.selected_deployment
|
|
73
|
+
previous = selected.profile_snapshot
|
|
74
|
+
image_sections = ["agent", "packages"]
|
|
75
|
+
launch_sections = ["home_volume", "provider", "proxy", "egress", "decant", "mounts"]
|
|
76
|
+
changed_image = [section for section in image_sections if snapshot.get(section) != previous.get(section)]
|
|
77
|
+
changed_launch = [section for section in launch_sections if snapshot.get(section) != previous.get(section)]
|
|
78
|
+
langfuse_changed = snapshot.get("langfuse") != previous.get("langfuse")
|
|
79
|
+
if langfuse_changed:
|
|
80
|
+
changed_image.append("langfuse")
|
|
81
|
+
changed = changed_image + changed_launch
|
|
82
|
+
if digest != selected.profile_digest and not changed:
|
|
83
|
+
changed_image.append("proxy CA contents")
|
|
84
|
+
changed.append("proxy CA contents")
|
|
85
|
+
if not changed:
|
|
86
|
+
actions = [PlanAction.NOOP]
|
|
87
|
+
else:
|
|
88
|
+
actions = []
|
|
89
|
+
if changed_image:
|
|
90
|
+
actions.append(PlanAction.UPDATE_IMAGE)
|
|
91
|
+
if changed_launch or langfuse_changed:
|
|
92
|
+
actions.append(PlanAction.UPDATE_LAUNCH)
|
|
93
|
+
return Plan(
|
|
94
|
+
profile_name=profile.name,
|
|
95
|
+
profile_digest=digest,
|
|
96
|
+
actions=actions,
|
|
97
|
+
changed_sections=changed,
|
|
98
|
+
warnings=warnings,
|
|
99
|
+
)
|