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.
Files changed (86) hide show
  1. agent_containers/__init__.py +1 -0
  2. agent_containers/__main__.py +6 -0
  3. agent_containers/_assets/agent-images/adal/Dockerfile +103 -0
  4. agent_containers/_assets/agent-images/adal/examples/egress-allowlist.txt +10 -0
  5. agent_containers/_assets/agent-images/adal/packages-apt.txt +13 -0
  6. agent_containers/_assets/agent-images/adal/packages-npm.txt +2 -0
  7. agent_containers/_assets/agent-images/adal/packages-uv.txt +6 -0
  8. agent_containers/_assets/agent-images/adal/tools-uv.txt +4 -0
  9. agent_containers/_assets/agent-images/agent-gateway/Dockerfile +50 -0
  10. agent_containers/_assets/agent-images/agent-gateway/entrypoint.sh +28 -0
  11. agent_containers/_assets/agent-images/agent-gateway/sshd_config.agent-gateway +11 -0
  12. agent_containers/_assets/agent-images/aider/Dockerfile +114 -0
  13. agent_containers/_assets/agent-images/aider/examples/egress-allowlist.txt +16 -0
  14. agent_containers/_assets/agent-images/aider/packages-apt.txt +16 -0
  15. agent_containers/_assets/agent-images/aider/packages-npm.txt +7 -0
  16. agent_containers/_assets/agent-images/aider/packages-uv.txt +9 -0
  17. agent_containers/_assets/agent-images/aider/tools-uv.txt +9 -0
  18. agent_containers/_assets/agent-images/claude-code/Dockerfile +171 -0
  19. agent_containers/_assets/agent-images/claude-code/entrypoint.sh +22 -0
  20. agent_containers/_assets/agent-images/claude-code/examples/egress-allowlist.txt +14 -0
  21. agent_containers/_assets/agent-images/claude-code/examples/settings.local-model.json +7 -0
  22. agent_containers/_assets/agent-images/claude-code/packages-apt.txt +16 -0
  23. agent_containers/_assets/agent-images/claude-code/packages-npm.txt +7 -0
  24. agent_containers/_assets/agent-images/claude-code/packages-uv.txt +9 -0
  25. agent_containers/_assets/agent-images/claude-code/plugin-marketplaces.txt +23 -0
  26. agent_containers/_assets/agent-images/claude-code/plugins.txt +13 -0
  27. agent_containers/_assets/agent-images/claude-code/proxy-ca.d/.keep +1 -0
  28. agent_containers/_assets/agent-images/claude-code/tools-uv.txt +9 -0
  29. agent_containers/_assets/agent-images/codex/Dockerfile +147 -0
  30. agent_containers/_assets/agent-images/codex/codex-wrapper.sh +13 -0
  31. agent_containers/_assets/agent-images/codex/examples/egress-allowlist.txt +10 -0
  32. agent_containers/_assets/agent-images/codex/packages-apt.txt +13 -0
  33. agent_containers/_assets/agent-images/codex/packages-npm.txt +2 -0
  34. agent_containers/_assets/agent-images/codex/packages-uv.txt +6 -0
  35. agent_containers/_assets/agent-images/codex/plugin-marketplaces.txt +2 -0
  36. agent_containers/_assets/agent-images/codex/plugins.txt +2 -0
  37. agent_containers/_assets/agent-images/codex/proxy-ca.d/.keep +1 -0
  38. agent_containers/_assets/agent-images/codex/tools-uv.txt +4 -0
  39. agent_containers/_assets/agent-images/hermes/Dockerfile +122 -0
  40. agent_containers/_assets/agent-images/hermes/entrypoint.sh +49 -0
  41. agent_containers/_assets/agent-images/hermes/packages-apt.txt +16 -0
  42. agent_containers/_assets/agent-images/hermes/packages-npm.txt +7 -0
  43. agent_containers/_assets/agent-images/hermes/packages-uv.txt +9 -0
  44. agent_containers/_assets/agent-images/hermes/proxy-ca.d/.keep +1 -0
  45. agent_containers/_assets/agent-images/hermes/tools-uv.txt +9 -0
  46. agent_containers/_assets/agent-images/kilo-code/Dockerfile +101 -0
  47. agent_containers/_assets/agent-images/kilo-code/examples/egress-allowlist.txt +7 -0
  48. agent_containers/_assets/agent-images/kilo-code/packages-apt.txt +5 -0
  49. agent_containers/_assets/agent-images/kilo-code/packages-npm.txt +1 -0
  50. agent_containers/_assets/agent-images/kilo-code/packages-uv.txt +4 -0
  51. agent_containers/_assets/agent-images/kilo-code/tools-uv.txt +4 -0
  52. agent_containers/_assets/agent-images/opencode/Dockerfile +126 -0
  53. agent_containers/_assets/agent-images/opencode/examples/egress-allowlist.txt +6 -0
  54. agent_containers/_assets/agent-images/opencode/packages-apt.txt +13 -0
  55. agent_containers/_assets/agent-images/opencode/packages-npm.txt +2 -0
  56. agent_containers/_assets/agent-images/opencode/packages-uv.txt +6 -0
  57. agent_containers/_assets/agent-images/opencode/proxy-ca.d/.keep +1 -0
  58. agent_containers/_assets/agent-images/opencode/tools-uv.txt +4 -0
  59. agent_containers/_assets/agent-images/qwen-code/Dockerfile +104 -0
  60. agent_containers/_assets/agent-images/qwen-code/examples/egress-allowlist.txt +10 -0
  61. agent_containers/_assets/agent-images/qwen-code/packages-apt.txt +13 -0
  62. agent_containers/_assets/agent-images/qwen-code/packages-npm.txt +2 -0
  63. agent_containers/_assets/agent-images/qwen-code/packages-uv.txt +6 -0
  64. agent_containers/_assets/agent-images/qwen-code/tools-uv.txt +4 -0
  65. agent_containers/_assets/agent-images/shared/egress-allowlist.sh +138 -0
  66. agent_containers/_assets/agent-images/shared/install-additional-packages.sh +79 -0
  67. agent_containers/_assets/agent-images/shared/install-workload-base.sh +46 -0
  68. agent_containers/_assets/agent-images/shared/workload-entrypoint.sh +82 -0
  69. agent_containers/_assets/agent-images/volume-bridge/Dockerfile +37 -0
  70. agent_containers/_assets/agent-images/volume-bridge/entrypoint.sh +29 -0
  71. agent_containers/_assets/agent-images/volume-bridge/serve.sh +66 -0
  72. agent_containers/assets.py +38 -0
  73. agent_containers/build_context.py +116 -0
  74. agent_containers/cli.py +212 -0
  75. agent_containers/creation.py +177 -0
  76. agent_containers/docker.py +562 -0
  77. agent_containers/lifecycle.py +325 -0
  78. agent_containers/planner.py +99 -0
  79. agent_containers/profile.py +377 -0
  80. agent_containers/shortcuts.py +159 -0
  81. agent_containers/state.py +154 -0
  82. agent_containers-0.1.0.dev0.dist-info/METADATA +195 -0
  83. agent_containers-0.1.0.dev0.dist-info/RECORD +86 -0
  84. agent_containers-0.1.0.dev0.dist-info/WHEEL +4 -0
  85. agent_containers-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  86. 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
+ )