devs-webadmin 2.0.12__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.
@@ -0,0 +1,10 @@
1
+ """Web Admin UI for DevContainer Management."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("devs-webadmin")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
9
+ __author__ = "Dan Lester"
10
+ __email__ = "dan@ideonate.com"
File without changes
@@ -0,0 +1,412 @@
1
+ """API routes for webadmin."""
2
+
3
+ import asyncio
4
+ import re
5
+ import subprocess
6
+ from typing import Dict, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ import structlog
12
+
13
+ from devs_common.core.container import ContainerManager
14
+ from devs_common.core.workspace import WorkspaceManager
15
+ from devs_common.core.project import Project
16
+ from devs_common.utils.repo_cache import RepoCache
17
+ from devs_common.utils.docker_client import DockerClient
18
+ from devs_common.devs_config import DevsConfigLoader
19
+ from devs_common.exceptions import DevsError
20
+
21
+ from ..config import config
22
+
23
+ logger = structlog.get_logger()
24
+
25
+ router = APIRouter(prefix="/api")
26
+
27
+ # Track running tunnel auth processes: container_name -> subprocess.Popen
28
+ _auth_processes: Dict[str, subprocess.Popen] = {}
29
+
30
+
31
+ class StartRequest(BaseModel):
32
+ repo: str # org/repo format
33
+ dev_name: str
34
+
35
+
36
+ class ContainerActionRequest(BaseModel):
37
+ container_name: str # Docker container name from list
38
+
39
+
40
+ def _get_repo_project(repo: str) -> Project:
41
+ """Clone/update a repo via cache and return a Project for it."""
42
+ repo_cache = RepoCache(cache_dir=config.repo_cache_dir)
43
+ repo_path = repo_cache.ensure_repo(repo)
44
+ return Project(project_dir=repo_path)
45
+
46
+
47
+ @router.get("/containers")
48
+ async def list_containers(repo: Optional[str] = None) -> dict:
49
+ """List containers, optionally filtered by repo."""
50
+ try:
51
+ if repo:
52
+ project = await asyncio.to_thread(_get_repo_project, repo)
53
+ container_manager = ContainerManager(project, config)
54
+ containers = await asyncio.to_thread(container_manager.list_containers)
55
+ else:
56
+ containers = await asyncio.to_thread(ContainerManager.list_all_containers)
57
+
58
+ return {
59
+ "containers": [
60
+ {
61
+ "name": c.name,
62
+ "dev_name": c.dev_name,
63
+ "project_name": c.project_name,
64
+ "status": c.status,
65
+ "container_id": c.container_id,
66
+ "created": c.created.isoformat() if c.created else None,
67
+ "mode": "live" if c.labels.get("devs.live") == "true" else "copy",
68
+ }
69
+ for c in containers
70
+ ]
71
+ }
72
+ except DevsError as e:
73
+ raise HTTPException(status_code=400, detail=str(e))
74
+
75
+
76
+ @router.post("/start")
77
+ async def start_container(request: StartRequest) -> dict:
78
+ """Start a named devcontainer for a repo."""
79
+ try:
80
+ project = await asyncio.to_thread(_get_repo_project, request.repo)
81
+ container_manager = ContainerManager(project, config)
82
+ workspace_manager = WorkspaceManager(project, config)
83
+
84
+ devs_env = DevsConfigLoader.load_env_vars(request.dev_name, project.info.name)
85
+ extra_env = devs_env if devs_env else None
86
+
87
+ workspace_dir = await asyncio.to_thread(
88
+ workspace_manager.create_workspace, request.dev_name
89
+ )
90
+
91
+ success = await asyncio.to_thread(
92
+ container_manager.ensure_container_running,
93
+ request.dev_name,
94
+ workspace_dir,
95
+ extra_env=extra_env,
96
+ )
97
+
98
+ if success:
99
+ return {
100
+ "status": "started",
101
+ "repo": request.repo,
102
+ "dev_name": request.dev_name,
103
+ }
104
+ else:
105
+ raise HTTPException(status_code=500, detail="Failed to start container")
106
+
107
+ except DevsError as e:
108
+ raise HTTPException(status_code=400, detail=str(e))
109
+
110
+
111
+ def _stop_by_name(container_name: str, remove: bool) -> bool:
112
+ """Stop (and optionally remove) a container by its Docker name."""
113
+ docker = DockerClient()
114
+ try:
115
+ docker.stop_container(container_name)
116
+ except Exception:
117
+ return False
118
+ if remove:
119
+ try:
120
+ docker.remove_container(container_name)
121
+ except Exception:
122
+ pass
123
+ return True
124
+
125
+
126
+ def _clean_workspace(project_name: str, dev_name: str) -> None:
127
+ """Remove workspace for a project/dev combination."""
128
+ # Build workspace path directly from project name + dev name
129
+ workspace_name = f"{project_name}-{dev_name}"
130
+ workspace_dir = config.workspaces_dir / workspace_name
131
+ if workspace_dir.exists():
132
+ import shutil
133
+ shutil.rmtree(workspace_dir)
134
+
135
+
136
+ @router.post("/stop")
137
+ async def stop_container(request: ContainerActionRequest) -> dict:
138
+ """Stop a container by Docker name (preserves state)."""
139
+ success = await asyncio.to_thread(_stop_by_name, request.container_name, False)
140
+
141
+ if not success:
142
+ raise HTTPException(
143
+ status_code=404,
144
+ detail=f"Container {request.container_name} not found or already stopped",
145
+ )
146
+
147
+ return {"status": "stopped", "container_name": request.container_name}
148
+
149
+
150
+ @router.post("/clean")
151
+ async def clean_container(request: ContainerActionRequest) -> dict:
152
+ """Stop, remove container and clean workspace by Docker name."""
153
+ # Get container labels before removing so we can find the workspace
154
+ docker = DockerClient()
155
+ dev_name = None
156
+ project_name = None
157
+ try:
158
+ containers = docker.find_containers_by_labels({"devs.managed": "true"})
159
+ for c in containers:
160
+ if c["name"] == request.container_name:
161
+ dev_name = c["labels"].get("devs.dev")
162
+ project_name = c["labels"].get("devs.project")
163
+ break
164
+ except Exception:
165
+ pass
166
+
167
+ # Stop and remove the container
168
+ await asyncio.to_thread(_stop_by_name, request.container_name, True)
169
+
170
+ # Clean workspace if we found the labels
171
+ if dev_name and project_name:
172
+ await asyncio.to_thread(_clean_workspace, project_name, dev_name)
173
+
174
+ return {"status": "cleaned", "container_name": request.container_name}
175
+
176
+
177
+ # --- Tunnel helpers ---
178
+
179
+ def _get_container_labels(container_name: str) -> dict:
180
+ """Get labels for a container by Docker name."""
181
+ docker = DockerClient()
182
+ containers = docker.find_containers_by_labels({"devs.managed": "true"})
183
+ for c in containers:
184
+ if c["name"] == container_name:
185
+ return c.get("labels", {})
186
+ return {}
187
+
188
+
189
+ def _make_tunnel_name(container_name: str) -> str:
190
+ """Derive tunnel name from container name (max 20 chars).
191
+
192
+ Container names are like dev-ideonate-devs-sally.
193
+ Tunnel name uses the same convention, truncated to 20 chars
194
+ keeping the dev name suffix.
195
+ """
196
+ name = container_name.replace(".", "-").replace("_", "-")
197
+ if len(name) <= 20:
198
+ return name
199
+ # Keep the last segment (dev name) and truncate the rest
200
+ parts = name.rsplit("-", 1)
201
+ if len(parts) == 2:
202
+ suffix = f"-{parts[1]}"
203
+ budget = 20 - len(suffix)
204
+ if budget >= 3:
205
+ return parts[0][:budget].rstrip("-") + suffix
206
+ return name[:20]
207
+
208
+
209
+ def _docker_exec(container_name: str, cmd: list, timeout: int = 10) -> subprocess.CompletedProcess:
210
+ """Run a docker exec command."""
211
+ full_cmd = ["docker", "exec", container_name] + cmd
212
+ return subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout)
213
+
214
+
215
+ # --- Tunnel endpoints ---
216
+
217
+ @router.get("/tunnel/status")
218
+ async def tunnel_status(container_name: str) -> dict:
219
+ """Get VS Code tunnel status for a container."""
220
+ try:
221
+ result = await asyncio.to_thread(
222
+ _docker_exec, container_name,
223
+ ["/usr/local/bin/code", "tunnel", "status"]
224
+ )
225
+ is_running = result.returncode == 0
226
+ tunnel_name = _make_tunnel_name(container_name)
227
+ return {
228
+ "running": is_running,
229
+ "message": result.stdout.strip() if is_running else (result.stderr.strip() or "No tunnel running"),
230
+ "tunnel_name": tunnel_name,
231
+ }
232
+ except subprocess.TimeoutExpired:
233
+ return {"running": False, "message": "Status check timed out"}
234
+ except Exception as e:
235
+ raise HTTPException(status_code=400, detail=str(e))
236
+
237
+
238
+ @router.post("/tunnel/start")
239
+ async def tunnel_start(request: ContainerActionRequest) -> dict:
240
+ """Start a VS Code tunnel in a container (background)."""
241
+ container_name = request.container_name
242
+ tunnel_name = _make_tunnel_name(container_name)
243
+ log_file = "/tmp/vscode-tunnel.log"
244
+ tunnel_cmd = f"/usr/local/bin/code tunnel --accept-server-license-terms --name {tunnel_name}"
245
+
246
+ def _start() -> dict:
247
+ # Start tunnel in background
248
+ subprocess.run(
249
+ ["docker", "exec", "-d", container_name,
250
+ "/bin/sh", "-c", f"{tunnel_cmd} > {log_file} 2>&1"],
251
+ check=True,
252
+ )
253
+
254
+ # Poll for startup (up to 15s)
255
+ import time
256
+ for i in range(15):
257
+ time.sleep(1)
258
+ result = subprocess.run(
259
+ ["docker", "exec", container_name, "cat", log_file],
260
+ capture_output=True, text=True,
261
+ )
262
+ output = result.stdout
263
+
264
+ if "Open this link" in output or "vscode.dev/tunnel" in output:
265
+ return {
266
+ "status": "running",
267
+ "tunnel_name": tunnel_name,
268
+ "web_url": f"https://vscode.dev/tunnel/{tunnel_name}",
269
+ "vscode_cmd": f"code --remote tunnel+{tunnel_name}",
270
+ }
271
+
272
+ if "log in" in output.lower() or "device" in output.lower() or "invalid" in output.lower():
273
+ # Auth needed - kill the failed attempt
274
+ subprocess.run(
275
+ ["docker", "exec", container_name, "/usr/local/bin/code", "tunnel", "kill"],
276
+ capture_output=True,
277
+ )
278
+ return {"status": "auth_required", "tunnel_name": tunnel_name}
279
+
280
+ # Timeout - tunnel may still be starting
281
+ return {
282
+ "status": "starting",
283
+ "tunnel_name": tunnel_name,
284
+ "message": "Tunnel may still be starting, check status",
285
+ }
286
+
287
+ try:
288
+ return await asyncio.to_thread(_start)
289
+ except Exception as e:
290
+ raise HTTPException(status_code=400, detail=str(e))
291
+
292
+
293
+ @router.post("/tunnel/kill")
294
+ async def tunnel_kill(request: ContainerActionRequest) -> dict:
295
+ """Kill a running VS Code tunnel."""
296
+ try:
297
+ result = await asyncio.to_thread(
298
+ _docker_exec, request.container_name,
299
+ ["/usr/local/bin/code", "tunnel", "kill"]
300
+ )
301
+ return {
302
+ "killed": result.returncode == 0,
303
+ "message": result.stdout.strip() or result.stderr.strip(),
304
+ }
305
+ except Exception as e:
306
+ raise HTTPException(status_code=400, detail=str(e))
307
+
308
+
309
+ @router.post("/tunnel/auth")
310
+ async def tunnel_auth_start(request: ContainerActionRequest) -> dict:
311
+ """Start tunnel auth (GitHub device flow).
312
+
313
+ Launches the login command, captures the device URL + code,
314
+ and returns them so the user can complete auth in their browser.
315
+ The process keeps running until auth completes or is cancelled.
316
+ """
317
+ container_name = request.container_name
318
+
319
+ # Kill any existing auth process for this container
320
+ if container_name in _auth_processes:
321
+ try:
322
+ _auth_processes[container_name].kill()
323
+ except Exception:
324
+ pass
325
+ del _auth_processes[container_name]
326
+
327
+ def _start_auth() -> dict:
328
+ proc = subprocess.Popen(
329
+ ["docker", "exec", container_name,
330
+ "/usr/local/bin/code", "tunnel", "user", "login",
331
+ "--provider", "github"],
332
+ stdout=subprocess.PIPE,
333
+ stderr=subprocess.STDOUT,
334
+ text=True,
335
+ )
336
+ _auth_processes[container_name] = proc
337
+
338
+ # Read output lines looking for the device URL and code
339
+ output_lines = []
340
+ device_url = None
341
+ device_code = None
342
+ import time
343
+ deadline = time.time() + 30 # 30s timeout for initial output
344
+
345
+ while time.time() < deadline:
346
+ line = proc.stdout.readline()
347
+ if not line:
348
+ if proc.poll() is not None:
349
+ break # Process exited
350
+ continue
351
+ output_lines.append(line.strip())
352
+ full_output = " ".join(output_lines)
353
+
354
+ # Look for GitHub device flow URL and code
355
+ url_match = re.search(r'(https://github\.com/login/device)', full_output)
356
+ if url_match:
357
+ device_url = url_match.group(1)
358
+
359
+ code_match = re.search(r'code\s+([A-Z0-9]{4}-[A-Z0-9]{4})', full_output, re.IGNORECASE)
360
+ if code_match:
361
+ device_code = code_match.group(1)
362
+
363
+ # If we have both, return immediately (process keeps running)
364
+ if device_url and device_code:
365
+ return {
366
+ "status": "waiting_for_browser",
367
+ "device_url": device_url,
368
+ "device_code": device_code,
369
+ "message": f"Open {device_url} and enter code {device_code}",
370
+ }
371
+
372
+ # Process may have exited successfully (already authed)
373
+ if proc.poll() is not None and proc.returncode == 0:
374
+ if container_name in _auth_processes:
375
+ del _auth_processes[container_name]
376
+ return {"status": "already_authenticated"}
377
+
378
+ # Timeout or couldn't parse
379
+ if container_name in _auth_processes:
380
+ try:
381
+ proc.kill()
382
+ except Exception:
383
+ pass
384
+ del _auth_processes[container_name]
385
+ return {
386
+ "status": "error",
387
+ "message": "Could not get device code. Output: " + "\n".join(output_lines[-5:]),
388
+ }
389
+
390
+ try:
391
+ return await asyncio.to_thread(_start_auth)
392
+ except Exception as e:
393
+ raise HTTPException(status_code=400, detail=str(e))
394
+
395
+
396
+ @router.get("/tunnel/auth/status")
397
+ async def tunnel_auth_status(container_name: str) -> dict:
398
+ """Check if a pending tunnel auth has completed."""
399
+ proc = _auth_processes.get(container_name)
400
+ if proc is None:
401
+ return {"status": "no_pending_auth"}
402
+
403
+ poll = proc.poll()
404
+ if poll is None:
405
+ return {"status": "waiting_for_browser"}
406
+
407
+ # Process finished
408
+ del _auth_processes[container_name]
409
+ if poll == 0:
410
+ return {"status": "authenticated"}
411
+ else:
412
+ return {"status": "failed", "message": f"Auth process exited with code {poll}"}
devs_webadmin/app.py ADDED
@@ -0,0 +1,26 @@
1
+ """FastAPI application for devs web admin."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.responses import FileResponse
8
+
9
+ from .api.routes import router as api_router
10
+
11
+ STATIC_DIR = Path(__file__).parent / "static"
12
+
13
+ app = FastAPI(
14
+ title="Devs Web Admin",
15
+ description="Web UI for managing devcontainers on a server",
16
+ version="0.1.0",
17
+ )
18
+
19
+ app.include_router(api_router)
20
+
21
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
22
+
23
+
24
+ @app.get("/")
25
+ async def index() -> FileResponse:
26
+ return FileResponse(str(STATIC_DIR / "index.html"))
devs_webadmin/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """CLI entry point for webadmin."""
2
+
3
+ import click
4
+ import uvicorn
5
+
6
+ from .config import config
7
+
8
+
9
+ @click.group()
10
+ def cli() -> None:
11
+ """Devs Web Admin - manage devcontainers from a web UI."""
12
+ pass
13
+
14
+
15
+ @cli.command()
16
+ @click.option("--host", default=None, help="Host to bind to")
17
+ @click.option("--port", default=None, type=int, help="Port to bind to")
18
+ @click.option("--reload", is_flag=True, help="Enable auto-reload for development")
19
+ def serve(host: str, port: int, reload: bool) -> None:
20
+ """Start the web admin server."""
21
+ host = host or config.host
22
+ port = port or config.port
23
+
24
+ click.echo(f"Starting devs-webadmin on {host}:{port}")
25
+ uvicorn.run(
26
+ "devs_webadmin.app:app",
27
+ host=host,
28
+ port=port,
29
+ reload=reload,
30
+ )
31
+
32
+
33
+ def main() -> None:
34
+ cli()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,43 @@
1
+ """Configuration for webadmin package."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict
6
+
7
+ from devs_common.config import BaseConfig
8
+
9
+
10
+ class WebAdminConfig(BaseConfig):
11
+ """Configuration settings for devs web admin."""
12
+
13
+ PROJECT_PREFIX = "dev"
14
+ WORKSPACES_DIR = Path.home() / ".devs" / "workspaces"
15
+ BRIDGE_DIR = Path.home() / ".devs" / "bridge"
16
+ REPO_CACHE_DIR = Path.home() / ".devs" / "repocache"
17
+
18
+ def __init__(self) -> None:
19
+ super().__init__()
20
+
21
+ repo_cache_env = os.getenv("DEVS_REPO_CACHE_DIR")
22
+ self.repo_cache_dir = Path(repo_cache_env) if repo_cache_env else self.REPO_CACHE_DIR
23
+
24
+ self.host = os.getenv("WEBADMIN_HOST", "0.0.0.0")
25
+ self.port = int(os.getenv("WEBADMIN_PORT", "8080"))
26
+
27
+ @property
28
+ def container_labels(self) -> Dict[str, str]:
29
+ labels = super().container_labels
30
+ labels["devs.source"] = "webadmin"
31
+ return labels
32
+
33
+ def get_default_workspaces_dir(self) -> Path:
34
+ return self.WORKSPACES_DIR
35
+
36
+ def get_default_bridge_dir(self) -> Path:
37
+ return self.BRIDGE_DIR
38
+
39
+ def get_default_project_prefix(self) -> str:
40
+ return self.PROJECT_PREFIX
41
+
42
+
43
+ config = WebAdminConfig()