devs-common 3.2.4__tar.gz → 3.3.1__tar.gz
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.
- {devs_common-3.2.4 → devs_common-3.3.1}/PKG-INFO +1 -1
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/container.py +170 -90
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/config_hash.py +55 -6
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/devcontainer.py +9 -3
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/PKG-INFO +1 -1
- {devs_common-3.2.4 → devs_common-3.3.1}/pyproject.toml +1 -1
- {devs_common-3.2.4 → devs_common-3.3.1}/LICENSE +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/README.md +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/__init__.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/config.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/__init__.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/project.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/workspace.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/devs_config.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/exceptions.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/Dockerfile +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/devcontainer.json +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/copy-ssh.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/post-create-wrapper.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/python-venv-integration.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/screenshot-webapp.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/setup-workspace.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/start-tunnel.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/tunnel-status.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/webapp-screenshot.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/init-firewall.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-devs-env.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-vscode-cache.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/start-services.sh +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/vscode-settings-reference.md +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/__init__.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/console.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/devcontainer_template.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/docker_client.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/file_utils.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/git_utils.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/repo_cache.py +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/SOURCES.txt +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/dependency_links.txt +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/requires.txt +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/top_level.txt +0 -0
- {devs_common-3.2.4 → devs_common-3.3.1}/setup.cfg +0 -0
|
@@ -40,7 +40,7 @@ from ..utils.docker_client import DockerClient
|
|
|
40
40
|
from ..utils.devcontainer import DevContainerCLI
|
|
41
41
|
from ..utils.devcontainer_template import get_template_dir
|
|
42
42
|
from ..utils.console import get_console
|
|
43
|
-
from ..utils.config_hash import compute_env_config_hash, get_env_mount_path
|
|
43
|
+
from ..utils.config_hash import compute_env_config_hash, compute_devcontainer_hash, get_env_mount_path
|
|
44
44
|
from .project import Project
|
|
45
45
|
|
|
46
46
|
# Initialize console based on environment
|
|
@@ -138,100 +138,84 @@ class ContainerManager:
|
|
|
138
138
|
|
|
139
139
|
return labels
|
|
140
140
|
|
|
141
|
-
def should_rebuild_image(self, dev_name: str) -> Tuple[bool, str]:
|
|
142
|
-
"""Check if devcontainer image should be rebuilt.
|
|
143
|
-
|
|
141
|
+
def should_rebuild_image(self, dev_name: str, project_labels: dict) -> Tuple[bool, str]:
|
|
142
|
+
"""Check if devcontainer image should be rebuilt based on content hash.
|
|
143
|
+
|
|
144
|
+
Compares the content hash of devcontainer files against the hash stored
|
|
145
|
+
in the existing container's label. This avoids false positives from fresh
|
|
146
|
+
checkouts, rebases, or other operations that update file timestamps without
|
|
147
|
+
changing the actual content.
|
|
148
|
+
|
|
144
149
|
Args:
|
|
145
150
|
dev_name: Development environment name
|
|
146
|
-
|
|
151
|
+
project_labels: Labels used to find the existing container
|
|
152
|
+
|
|
147
153
|
Returns:
|
|
148
154
|
Tuple of (should_rebuild, reason)
|
|
149
155
|
"""
|
|
150
156
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if not
|
|
156
|
-
return False, "No existing
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
self.project.project_dir / "Dockerfile",
|
|
172
|
-
self.project.project_dir / "docker-compose.yml",
|
|
173
|
-
self.project.project_dir / "docker-compose.yaml",
|
|
174
|
-
]
|
|
175
|
-
|
|
176
|
-
for file_path in devcontainer_files:
|
|
177
|
-
if file_path.exists():
|
|
178
|
-
if file_path.is_file():
|
|
179
|
-
file_time = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc)
|
|
180
|
-
if file_time > newest_image_time:
|
|
181
|
-
return True, f"File newer than image: {file_path.name}"
|
|
182
|
-
elif file_path.is_dir():
|
|
183
|
-
# For directories, find the newest file
|
|
184
|
-
newest_file_time = None
|
|
185
|
-
newest_file = None
|
|
186
|
-
|
|
187
|
-
for item in file_path.rglob('*'):
|
|
188
|
-
if item.is_file():
|
|
189
|
-
item_time = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
|
|
190
|
-
if not newest_file_time or item_time > newest_file_time:
|
|
191
|
-
newest_file_time = item_time
|
|
192
|
-
newest_file = item
|
|
193
|
-
|
|
194
|
-
if newest_file_time and newest_file_time > newest_image_time:
|
|
195
|
-
return True, f"File newer than image: {newest_file}"
|
|
196
|
-
|
|
197
|
-
return False, "Image is up to date"
|
|
198
|
-
|
|
157
|
+
current_hash = compute_devcontainer_hash(self.project.project_dir)
|
|
158
|
+
|
|
159
|
+
# Look for an existing container to compare against
|
|
160
|
+
existing_containers = self.docker.find_containers_by_labels(project_labels)
|
|
161
|
+
if not existing_containers:
|
|
162
|
+
return False, "No existing container to compare against"
|
|
163
|
+
|
|
164
|
+
existing_container = existing_containers[0]
|
|
165
|
+
existing_labels = existing_container.get('labels', {})
|
|
166
|
+
stored_hash = existing_labels.get('devs.devcontainer-hash', '')
|
|
167
|
+
|
|
168
|
+
if not stored_hash:
|
|
169
|
+
# No hash stored yet — don't trigger a rebuild speculatively
|
|
170
|
+
return False, "No devcontainer hash label on existing container"
|
|
171
|
+
|
|
172
|
+
if stored_hash != current_hash:
|
|
173
|
+
return True, f"Devcontainer files changed ({stored_hash} → {current_hash})"
|
|
174
|
+
|
|
175
|
+
return False, "Devcontainer files unchanged"
|
|
176
|
+
|
|
199
177
|
except (DockerError, OSError) as e:
|
|
200
178
|
console.print(f"[yellow]Warning: Could not check image rebuild status: {e}[/yellow]")
|
|
201
179
|
return False, "Could not determine rebuild status"
|
|
202
180
|
|
|
203
181
|
def ensure_container_running(
|
|
204
|
-
self,
|
|
182
|
+
self,
|
|
205
183
|
dev_name: str,
|
|
206
184
|
workspace_dir: Path,
|
|
207
185
|
force_rebuild: bool = False,
|
|
186
|
+
check_rebuild: bool = True,
|
|
208
187
|
debug: bool = False,
|
|
209
188
|
live: bool = False,
|
|
210
189
|
extra_env: Optional[Dict[str, str]] = None
|
|
211
190
|
) -> bool:
|
|
212
191
|
"""Ensure a container is running for the specified dev environment.
|
|
213
|
-
|
|
192
|
+
|
|
214
193
|
Args:
|
|
215
194
|
dev_name: Development environment name
|
|
216
195
|
workspace_dir: Workspace directory path
|
|
217
196
|
force_rebuild: Force rebuild even if not needed
|
|
197
|
+
check_rebuild: Automatically rebuild if devcontainer files have changed.
|
|
198
|
+
Set to False (e.g. for the CLI) to only rebuild when force_rebuild=True.
|
|
218
199
|
debug: Show debug output for devcontainer operations
|
|
219
200
|
live: Whether to use live mode (mount current directory instead of copying)
|
|
220
201
|
extra_env: Additional environment variables to pass to container
|
|
221
|
-
|
|
202
|
+
|
|
222
203
|
Returns:
|
|
223
204
|
True if container is running successfully
|
|
224
|
-
|
|
205
|
+
|
|
225
206
|
Raises:
|
|
226
207
|
ContainerError: If container operations fail
|
|
227
208
|
"""
|
|
228
209
|
workspace_info = self._get_container_info(dev_name, live)
|
|
229
210
|
container_name = workspace_info["container_name"]
|
|
230
211
|
project_labels = self._get_project_labels(dev_name, live)
|
|
231
|
-
|
|
212
|
+
|
|
232
213
|
try:
|
|
233
214
|
# Check if we need to rebuild
|
|
234
|
-
|
|
215
|
+
if check_rebuild:
|
|
216
|
+
rebuild_needed, rebuild_reason = self.should_rebuild_image(dev_name, project_labels)
|
|
217
|
+
else:
|
|
218
|
+
rebuild_needed, rebuild_reason = False, "Auto-rebuild disabled"
|
|
235
219
|
if rebuild_needed or force_rebuild:
|
|
236
220
|
if force_rebuild:
|
|
237
221
|
console.print(f" 🔄 Forcing image rebuild for {dev_name}...")
|
|
@@ -248,9 +232,10 @@ class ContainerManager:
|
|
|
248
232
|
console.print(f"[dim]Removing container: {existing_container['name']}[/dim]")
|
|
249
233
|
self.docker.remove_container(existing_container['name'])
|
|
250
234
|
|
|
251
|
-
# Compute current config
|
|
235
|
+
# Compute current config hashes for comparison
|
|
252
236
|
env_mount_path = get_env_mount_path(self.project.info.name)
|
|
253
237
|
current_config_hash = compute_env_config_hash(self.project.info.name)
|
|
238
|
+
current_devcontainer_hash = compute_devcontainer_hash(self.project.project_dir)
|
|
254
239
|
console.print(f" 📁 Env mount: {env_mount_path} (hash: {current_config_hash})")
|
|
255
240
|
|
|
256
241
|
# Check if container is already running
|
|
@@ -267,13 +252,19 @@ class ContainerManager:
|
|
|
267
252
|
|
|
268
253
|
console.print(f" 🔍 Found container: {existing_container['name']} (status: {existing_container['status']}, hash: {existing_config_hash or 'none'})")
|
|
269
254
|
|
|
270
|
-
# Check if config hash has changed
|
|
255
|
+
# Check if config hash has changed (only when auto-rebuild checks are enabled)
|
|
271
256
|
if existing_config_hash and existing_config_hash != current_config_hash:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
257
|
+
if check_rebuild:
|
|
258
|
+
config_hash_changed = True
|
|
259
|
+
console.print(f" 🔄 Config hash changed ({existing_config_hash} → {current_config_hash}), will restart container")
|
|
260
|
+
else:
|
|
261
|
+
console.print(f" ℹ️ Env config has changed since container was created — use --rebuild-if-changed to restart")
|
|
262
|
+
|
|
263
|
+
# Check devcontainer hash when not rebuilding, to inform user of changes
|
|
264
|
+
if not check_rebuild:
|
|
265
|
+
existing_devcontainer_hash = existing_labels.get('devs.devcontainer-hash', '')
|
|
266
|
+
if existing_devcontainer_hash and existing_devcontainer_hash != current_devcontainer_hash:
|
|
267
|
+
console.print(f" ℹ️ Devcontainer files have changed since image was built — use --rebuild-if-changed to rebuild")
|
|
277
268
|
|
|
278
269
|
# Check if existing container matches the requested mode
|
|
279
270
|
if existing_is_live != live:
|
|
@@ -335,7 +326,8 @@ class ContainerManager:
|
|
|
335
326
|
config_path=config_path,
|
|
336
327
|
live=live,
|
|
337
328
|
extra_env=extra_env,
|
|
338
|
-
config_hash=current_config_hash
|
|
329
|
+
config_hash=current_config_hash,
|
|
330
|
+
devcontainer_hash=current_devcontainer_hash
|
|
339
331
|
)
|
|
340
332
|
|
|
341
333
|
if not success:
|
|
@@ -628,8 +620,8 @@ class ContainerManager:
|
|
|
628
620
|
console.print(f" ▶️ Starting stopped container: {container_id}")
|
|
629
621
|
self.docker.start_container(container_id)
|
|
630
622
|
else:
|
|
631
|
-
# Ensure container is running (may create/restart as needed)
|
|
632
|
-
if not self.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env):
|
|
623
|
+
# Ensure container is running (may create/restart as needed, but never auto-rebuild)
|
|
624
|
+
if not self.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
|
|
633
625
|
raise ContainerError(f"Failed to start container for {dev_name}")
|
|
634
626
|
|
|
635
627
|
return container_name, container_workspace_dir
|
|
@@ -916,6 +908,22 @@ class ContainerManager:
|
|
|
916
908
|
console.print("[yellow]Authentication was cancelled or failed.[/yellow]")
|
|
917
909
|
return False
|
|
918
910
|
|
|
911
|
+
def _kill_tunnel_processes(self, container_name: str) -> None:
|
|
912
|
+
"""Kill all tunnel processes in the container.
|
|
913
|
+
|
|
914
|
+
Uses pkill to ensure detached shell wrappers and tunnel processes
|
|
915
|
+
are fully terminated, not just the service-level shutdown that
|
|
916
|
+
'code tunnel kill' provides.
|
|
917
|
+
"""
|
|
918
|
+
subprocess.run(
|
|
919
|
+
['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
|
|
920
|
+
capture_output=True
|
|
921
|
+
)
|
|
922
|
+
subprocess.run(
|
|
923
|
+
['docker', 'exec', container_name, 'pkill', '-f', 'code tunnel'],
|
|
924
|
+
capture_output=True
|
|
925
|
+
)
|
|
926
|
+
|
|
919
927
|
def _start_and_poll_tunnel(self, container_name: str, dev_name: str, tunnel_cmd: str,
|
|
920
928
|
log_file: str, vscode_cmd: str, web_url: str, debug: bool = False) -> bool:
|
|
921
929
|
"""Start a detached tunnel and poll the log for startup confirmation.
|
|
@@ -923,6 +931,14 @@ class ContainerManager:
|
|
|
923
931
|
Returns:
|
|
924
932
|
True if tunnel started (or timed out), False if auth is needed.
|
|
925
933
|
"""
|
|
934
|
+
# Kill any stale tunnel processes and clear the log before starting
|
|
935
|
+
self._kill_tunnel_processes(container_name)
|
|
936
|
+
subprocess.run(
|
|
937
|
+
['docker', 'exec', container_name, '/bin/sh', '-c', f'> {log_file}'],
|
|
938
|
+
capture_output=True
|
|
939
|
+
)
|
|
940
|
+
time.sleep(1)
|
|
941
|
+
|
|
926
942
|
subprocess.run(
|
|
927
943
|
['docker', 'exec', '-d', container_name,
|
|
928
944
|
'/bin/sh', '-c', f'{tunnel_cmd} > {log_file} 2>&1'],
|
|
@@ -950,10 +966,7 @@ class ContainerManager:
|
|
|
950
966
|
return True
|
|
951
967
|
|
|
952
968
|
if 'log in' in output.lower() or 'device' in output.lower() or 'invalid' in output.lower():
|
|
953
|
-
|
|
954
|
-
['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
|
|
955
|
-
capture_output=True
|
|
956
|
-
)
|
|
969
|
+
self._kill_tunnel_processes(container_name)
|
|
957
970
|
return False
|
|
958
971
|
|
|
959
972
|
if debug and output.strip():
|
|
@@ -1086,14 +1099,92 @@ class ContainerManager:
|
|
|
1086
1099
|
|
|
1087
1100
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
1088
1101
|
|
|
1102
|
+
# Get auth info (logged in user/provider)
|
|
1103
|
+
user_result = subprocess.run(
|
|
1104
|
+
['docker', 'exec', container_name,
|
|
1105
|
+
'/usr/local/bin/code', 'tunnel', 'user', 'show'],
|
|
1106
|
+
capture_output=True, text=True
|
|
1107
|
+
)
|
|
1108
|
+
auth_info = user_result.stdout.strip() if user_result.returncode == 0 else None
|
|
1109
|
+
|
|
1089
1110
|
if result.returncode == 0:
|
|
1090
|
-
|
|
1111
|
+
raw = result.stdout.strip()
|
|
1112
|
+
return True, self._format_tunnel_status(raw, dev_name, auth_info)
|
|
1091
1113
|
else:
|
|
1092
1114
|
return False, result.stderr.strip() if result.stderr else "No tunnel running"
|
|
1093
1115
|
|
|
1094
1116
|
except (DockerError, subprocess.SubprocessError) as e:
|
|
1095
1117
|
raise ContainerError(f"Failed to get tunnel status in {dev_name}: {e}")
|
|
1096
1118
|
|
|
1119
|
+
def _format_tunnel_status(self, raw_json: str, dev_name: str, auth_info: Optional[str] = None) -> str:
|
|
1120
|
+
"""Format tunnel status JSON into human-readable output."""
|
|
1121
|
+
import json
|
|
1122
|
+
try:
|
|
1123
|
+
data = json.loads(raw_json)
|
|
1124
|
+
except json.JSONDecodeError:
|
|
1125
|
+
return raw_json
|
|
1126
|
+
|
|
1127
|
+
tunnel = data.get("tunnel")
|
|
1128
|
+
if not tunnel:
|
|
1129
|
+
lines = ["[dim]● No tunnel running[/dim]"]
|
|
1130
|
+
if auth_info:
|
|
1131
|
+
lines.append(f" Auth: {auth_info}")
|
|
1132
|
+
return "\n".join(lines)
|
|
1133
|
+
name = tunnel.get("name") or "unknown"
|
|
1134
|
+
state = tunnel.get("tunnel", "Unknown")
|
|
1135
|
+
started_at = tunnel.get("started_at")
|
|
1136
|
+
connected_at = tunnel.get("last_connected_at")
|
|
1137
|
+
disconnected_at = tunnel.get("last_disconnected_at")
|
|
1138
|
+
fail_reason = tunnel.get("last_fail_reason")
|
|
1139
|
+
|
|
1140
|
+
lines = []
|
|
1141
|
+
|
|
1142
|
+
# Status line with color
|
|
1143
|
+
if state == "Connected":
|
|
1144
|
+
lines.append(f"[bold green]● Connected[/bold green] ({name})")
|
|
1145
|
+
elif state == "Disconnected":
|
|
1146
|
+
lines.append(f"[bold red]● Disconnected[/bold red] ({name})")
|
|
1147
|
+
else:
|
|
1148
|
+
lines.append(f"[bold yellow]● {state}[/bold yellow] ({name})")
|
|
1149
|
+
|
|
1150
|
+
# Timestamps
|
|
1151
|
+
if started_at:
|
|
1152
|
+
lines.append(f" Started: {self._format_timestamp(started_at)}")
|
|
1153
|
+
if connected_at:
|
|
1154
|
+
lines.append(f" Connected: {self._format_timestamp(connected_at)}")
|
|
1155
|
+
if disconnected_at:
|
|
1156
|
+
lines.append(f" Disconnected: {self._format_timestamp(disconnected_at)}")
|
|
1157
|
+
if fail_reason:
|
|
1158
|
+
lines.append(f" Fail reason: {fail_reason}")
|
|
1159
|
+
if auth_info:
|
|
1160
|
+
lines.append(f" Auth: {auth_info}")
|
|
1161
|
+
|
|
1162
|
+
# Connection info when connected
|
|
1163
|
+
if state == "Connected":
|
|
1164
|
+
tunnel_name = self._get_tunnel_name(dev_name)
|
|
1165
|
+
lines.append("")
|
|
1166
|
+
lines.append("[bold]Connect:[/bold]")
|
|
1167
|
+
lines.append(f" Desktop: code --remote tunnel+{tunnel_name}")
|
|
1168
|
+
lines.append(f" Browser: https://vscode.dev/tunnel/{tunnel_name}")
|
|
1169
|
+
|
|
1170
|
+
return "\n".join(lines)
|
|
1171
|
+
|
|
1172
|
+
@staticmethod
|
|
1173
|
+
def _format_timestamp(ts: str) -> str:
|
|
1174
|
+
"""Format an ISO timestamp into a friendly local time string."""
|
|
1175
|
+
from datetime import datetime
|
|
1176
|
+
try:
|
|
1177
|
+
# Handle nanosecond precision by truncating to microseconds
|
|
1178
|
+
if "." in ts:
|
|
1179
|
+
base, frac = ts.split(".")
|
|
1180
|
+
frac = frac.rstrip("Z")[:6]
|
|
1181
|
+
ts = f"{base}.{frac}Z"
|
|
1182
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
1183
|
+
local_dt = dt.astimezone()
|
|
1184
|
+
return local_dt.strftime("%H:%M:%S %Z")
|
|
1185
|
+
except (ValueError, TypeError):
|
|
1186
|
+
return ts
|
|
1187
|
+
|
|
1097
1188
|
def kill_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> bool:
|
|
1098
1189
|
"""Kill a running VS Code tunnel in the container.
|
|
1099
1190
|
|
|
@@ -1116,24 +1207,13 @@ class ContainerManager:
|
|
|
1116
1207
|
dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
|
|
1117
1208
|
)
|
|
1118
1209
|
|
|
1119
|
-
# Kill the tunnel
|
|
1120
|
-
cmd = [
|
|
1121
|
-
'docker', 'exec',
|
|
1122
|
-
container_name,
|
|
1123
|
-
'/usr/local/bin/code', 'tunnel', 'kill'
|
|
1124
|
-
]
|
|
1125
|
-
|
|
1210
|
+
# Kill the tunnel (both service-level and process-level)
|
|
1126
1211
|
if debug:
|
|
1127
|
-
console.print(f"[dim]
|
|
1128
|
-
|
|
1129
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
1212
|
+
console.print(f"[dim]Killing tunnel processes in {container_name}[/dim]")
|
|
1130
1213
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
else:
|
|
1135
|
-
console.print(f" No tunnel to kill in: {dev_name}")
|
|
1136
|
-
return False
|
|
1214
|
+
self._kill_tunnel_processes(container_name)
|
|
1215
|
+
console.print(f" Tunnel killed in: {dev_name}")
|
|
1216
|
+
return True
|
|
1137
1217
|
|
|
1138
1218
|
except (DockerError, subprocess.SubprocessError) as e:
|
|
1139
1219
|
raise ContainerError(f"Failed to kill tunnel in {dev_name}: {e}")
|
|
@@ -39,11 +39,60 @@ def compute_env_config_hash(project_name: str) -> str:
|
|
|
39
39
|
Short hash string (first 12 chars of SHA256)
|
|
40
40
|
"""
|
|
41
41
|
env_path = get_env_mount_path(project_name)
|
|
42
|
-
return
|
|
42
|
+
return _hash_directory_contents(env_path)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def
|
|
46
|
-
"""
|
|
45
|
+
def compute_devcontainer_hash(project_dir: Path) -> str:
|
|
46
|
+
"""Compute a content hash of devcontainer-related files.
|
|
47
|
+
|
|
48
|
+
Uses file contents (not mtimes) so that fresh checkouts or rebases that
|
|
49
|
+
don't change the actual devcontainer files produce the same hash.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
project_dir: Root directory of the project
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Short hash string (first 12 chars of SHA256)
|
|
56
|
+
"""
|
|
57
|
+
hasher = hashlib.sha256()
|
|
58
|
+
|
|
59
|
+
devcontainer_paths = [
|
|
60
|
+
project_dir / ".devcontainer",
|
|
61
|
+
project_dir / "Dockerfile",
|
|
62
|
+
project_dir / "docker-compose.yml",
|
|
63
|
+
project_dir / "docker-compose.yaml",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
found_any = False
|
|
67
|
+
try:
|
|
68
|
+
for path in devcontainer_paths:
|
|
69
|
+
if not path.exists():
|
|
70
|
+
continue
|
|
71
|
+
found_any = True
|
|
72
|
+
if path.is_file():
|
|
73
|
+
rel = path.relative_to(project_dir)
|
|
74
|
+
hasher.update(str(rel).encode())
|
|
75
|
+
hasher.update(path.read_bytes())
|
|
76
|
+
elif path.is_dir():
|
|
77
|
+
for item in sorted(path.rglob("*")):
|
|
78
|
+
if item.is_file():
|
|
79
|
+
rel = item.relative_to(project_dir)
|
|
80
|
+
hasher.update(str(rel).encode())
|
|
81
|
+
hasher.update(item.read_bytes())
|
|
82
|
+
except (OSError, PermissionError):
|
|
83
|
+
hasher.update(b"error")
|
|
84
|
+
|
|
85
|
+
if not found_any:
|
|
86
|
+
hasher.update(b"no-devcontainer")
|
|
87
|
+
|
|
88
|
+
return hasher.hexdigest()[:12]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _hash_directory_contents(directory: Path) -> str:
|
|
92
|
+
"""Hash the contents of all files in a directory.
|
|
93
|
+
|
|
94
|
+
Uses file contents rather than mtimes so that fresh checkouts or rebases
|
|
95
|
+
that don't change actual file content produce the same hash.
|
|
47
96
|
|
|
48
97
|
Args:
|
|
49
98
|
directory: Directory to hash
|
|
@@ -65,10 +114,10 @@ def _hash_directory_mtimes(directory: Path) -> str:
|
|
|
65
114
|
files = sorted(directory.rglob("*"))
|
|
66
115
|
for file_path in files:
|
|
67
116
|
if file_path.is_file():
|
|
68
|
-
# Include relative path and
|
|
117
|
+
# Include relative path and file contents in hash
|
|
69
118
|
rel_path = file_path.relative_to(directory)
|
|
70
|
-
|
|
71
|
-
hasher.update(
|
|
119
|
+
hasher.update(str(rel_path).encode())
|
|
120
|
+
hasher.update(file_path.read_bytes())
|
|
72
121
|
except (OSError, PermissionError):
|
|
73
122
|
hasher.update(b"error")
|
|
74
123
|
|
|
@@ -238,10 +238,11 @@ class DevContainerCLI:
|
|
|
238
238
|
config_path: Optional[Path] = None,
|
|
239
239
|
live: bool = False,
|
|
240
240
|
extra_env: Optional[dict] = None,
|
|
241
|
-
config_hash: Optional[str] = None
|
|
241
|
+
config_hash: Optional[str] = None,
|
|
242
|
+
devcontainer_hash: Optional[str] = None
|
|
242
243
|
) -> bool:
|
|
243
244
|
"""Start a devcontainer.
|
|
244
|
-
|
|
245
|
+
|
|
245
246
|
Args:
|
|
246
247
|
workspace_folder: Path to workspace folder on host
|
|
247
248
|
dev_name: Development environment name
|
|
@@ -254,7 +255,8 @@ class DevContainerCLI:
|
|
|
254
255
|
config_path: Optional path to external devcontainer config directory
|
|
255
256
|
live: Whether to use live mode (mount current directory)
|
|
256
257
|
extra_env: Additional environment variables to pass to container
|
|
257
|
-
config_hash: Optional hash of config files for detecting changes
|
|
258
|
+
config_hash: Optional hash of env config files for detecting changes
|
|
259
|
+
devcontainer_hash: Optional content hash of devcontainer files for rebuild detection
|
|
258
260
|
|
|
259
261
|
Returns:
|
|
260
262
|
True if successful
|
|
@@ -291,6 +293,10 @@ class DevContainerCLI:
|
|
|
291
293
|
if config_hash:
|
|
292
294
|
cmd.extend(['--id-label', f'devs.config-hash={config_hash}'])
|
|
293
295
|
|
|
296
|
+
# Add devcontainer content hash label for rebuild detection
|
|
297
|
+
if devcontainer_hash:
|
|
298
|
+
cmd.extend(['--id-label', f'devs.devcontainer-hash={devcontainer_hash}'])
|
|
299
|
+
|
|
294
300
|
# Add extra container labels from config if provided
|
|
295
301
|
if self.config and hasattr(self.config, 'container_labels'):
|
|
296
302
|
for k, v in self.config.container_labels.items():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/post-create-wrapper.sh
RENAMED
|
File without changes
|
{devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/python-venv-integration.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-devs-env.sh
RENAMED
|
File without changes
|
{devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-vscode-cache.sh
RENAMED
|
File without changes
|
{devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/start-services.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|