ddd92e3dd4
- Add ToolType.home_directory column with default /home/user
- Add Alembic migration to add column, set existing rows, and rewrite
/workspace to /home/user/{{WORKSPACE_NAME}} in legacy templates
- Add merge migration fc8f1a20cbf6 to resolve Alembic multiple heads
- Update manifest compiler to honor manifest.home_directory for HOME,
WORKDIR, /workspace symlink, and default repo mount target
- Update legacy dockerfile/compose instance generation to use
tool_type.home_directory
- Thread resolved home_dir through config profile and git mount expansion
- Generate entrypoint permission fixer to chown home/mounts at startup
- Update base.dockerfile with sudo/passwordless sudo for permission fixer
- Add unit tests for manifest compiler, instance service, and migrations
- Add placeholder integration test for container lifecycle
- Update openspec/tasks/home-path-expansion.md task checkboxes
- Update project maps for modified files
Quality gates: py_compile, ruff, mypy, pytest tests/unit (205 passed),
pytest tests/integration (110 passed, 35 skipped). Alembic round-trip
and container lifecycle integration tests require Docker/PostgreSQL.
134 lines
3.9 KiB
Python
134 lines
3.9 KiB
Python
"""Docker Compose file generation and manipulation."""
|
|
|
|
import logging
|
|
import subprocess
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
|
"""Sort volume strings so parent paths come before child paths.
|
|
|
|
Docker Compose mounts volumes in array order. A later mount at a parent
|
|
path hides earlier mounts at child paths. By sorting shallow paths first
|
|
and deep paths last, deeper (more specific) mounts overlay correctly.
|
|
|
|
Volume format: source:target or source:target:type
|
|
|
|
Args:
|
|
volumes: List of Docker volume mount strings.
|
|
|
|
Returns:
|
|
Sorted list with parent paths before child paths.
|
|
"""
|
|
|
|
def _target_depth(vol: str) -> int:
|
|
parts = vol.split(":")
|
|
if len(parts) < 2:
|
|
return 0
|
|
target = parts[1].rstrip("/")
|
|
if not target or target == "/":
|
|
return 0
|
|
return target.count("/")
|
|
|
|
# Detect duplicate targets and warn
|
|
targets = []
|
|
for vol in volumes:
|
|
parts = vol.split(":")
|
|
targets.append(parts[1] if len(parts) > 1 else "")
|
|
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
|
if dupes:
|
|
logger.warning("Duplicate mount targets detected: %s", dupes)
|
|
|
|
# Stable sort: parent paths first, child paths last
|
|
return sorted(volumes, key=_target_depth)
|
|
|
|
|
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
|
"""Render a Docker Compose template with variable substitution.
|
|
|
|
Args:
|
|
template: The compose template string
|
|
variables: Dictionary of variable names to values
|
|
|
|
Returns:
|
|
Rendered compose file content
|
|
"""
|
|
result = template
|
|
for key, value in variables.items():
|
|
placeholder = f"{{{{{key}}}}}"
|
|
result = result.replace(placeholder, str(value))
|
|
|
|
# Convenience aliases so legacy and migrated templates can use lowercase
|
|
# placeholders without changing every stored template.
|
|
aliases = {
|
|
"{{workspace_name}}": "WORKSPACE_NAME",
|
|
"{{home_directory}}": "HOME_DIRECTORY",
|
|
}
|
|
for alias_placeholder, key in aliases.items():
|
|
if alias_placeholder in result:
|
|
result = result.replace(
|
|
alias_placeholder, str(variables.get(key, "workspace"))
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
|
"""Write the rendered compose file to the instance directory.
|
|
|
|
Args:
|
|
instance_dir: Path to instance directory
|
|
content: Rendered compose content
|
|
|
|
Returns:
|
|
Path to the compose file
|
|
"""
|
|
compose_path = Path(instance_dir) / "docker-compose.yml"
|
|
compose_path.write_text(content)
|
|
return str(compose_path)
|
|
|
|
|
|
def execute_compose_command(
|
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
|
) -> tuple[int, str, str]:
|
|
"""Execute a docker compose command.
|
|
|
|
Args:
|
|
compose_path: Path to docker-compose.yml
|
|
action: The compose action (up, down, start, stop, restart)
|
|
timeout: Command timeout in seconds
|
|
env_file: Optional path to .env file for environment variables
|
|
|
|
Returns:
|
|
Tuple of (returncode, stdout, stderr)
|
|
"""
|
|
instance_dir = Path(compose_path).parent
|
|
|
|
cmd = ["docker", "compose", "-f", compose_path]
|
|
|
|
if env_file:
|
|
cmd.extend(["--env-file", env_file])
|
|
|
|
if action == "up":
|
|
cmd.extend(["up", "-d", "--force-recreate"])
|
|
elif action == "down":
|
|
cmd.extend(["down", "-v"])
|
|
elif action in ("start", "stop", "restart"):
|
|
cmd.append(action)
|
|
else:
|
|
raise ValueError(f"Unknown compose action: {action}")
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=str(instance_dir),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
|
|
return result.returncode, result.stdout, result.stderr
|