fix: pi container repo mount target and npm update permissions
- Add Alembic migration to update built-in pi-agent manifest:
* repo mount target from /workspace to ~/{{WORKSPACE_NAME}}
* keep /workspace as compatibility symlink via working_dir
* update startup chown target to $HOME/$WORKSPACE_NAME
- Pass REPO_NAME and WORKSPACE_NAME to compile_compose from instance_service
- Substitute {{WORKSPACE_NAME}} in manifest mount targets and expose it as
a container env var so the entrypoint can create the /workspace symlink
- Generate entrypoint workspace symlink from runtime WORKSPACE_NAME env var
- Install npm_global packages into {home_dir}/.npm-global with PATH so the
non-root container user can update global packages
- Update manifest compiler unit tests for the new behavior
Quality gates:
- pytest tests/unit: 207 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: apps/api/src/services
|
||||
|
||||
## role
|
||||
Marks the services directory as a Python package for organizing business logic and service-layer abstractions.
|
||||
Provides business logic and service layer abstractions for the API application.
|
||||
## parent
|
||||
index: apps/api/src/.pi-map.index.md
|
||||
map: apps/api/src/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: apps/api/src/services
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
|
||||
## role
|
||||
This directory serves as a Python package namespace for organizing service-layer modules in the API application.
|
||||
Provides business logic and service layer abstractions for the API application.
|
||||
## files
|
||||
- __init__.py | Empty file with no functionality
|
||||
## arch
|
||||
Standard Python package structure using __init__.py to define an importable module directory, following conventional layered architecture patterns.
|
||||
Minimal or placeholder package structure with no implemented services yet, following standard Python package conventions.
|
||||
## tags
|
||||
init, empty, functionality
|
||||
## symbols
|
||||
|
||||
@@ -143,13 +143,6 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||
lines.append("")
|
||||
|
||||
# NPM global packages
|
||||
npm_packages = manifest.get("packages", {}).get("npm_global", [])
|
||||
if npm_packages:
|
||||
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
|
||||
lines.append(f"RUN npm install -g {pkg_list}")
|
||||
lines.append("")
|
||||
|
||||
# Pip packages
|
||||
pip_packages = manifest.get("packages", {}).get("pip", [])
|
||||
if pip_packages:
|
||||
@@ -161,6 +154,7 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
user = manifest.get("user")
|
||||
home_dir = get_manifest_home_dir(manifest)
|
||||
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
|
||||
npm_prefix = ""
|
||||
if user:
|
||||
name = user["name"]
|
||||
uid = user["uid"]
|
||||
@@ -195,6 +189,20 @@ def compile_dockerfile(manifest: dict) -> str:
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# NPM global packages: install into a user-writable prefix so the
|
||||
# container user can update global packages without touching
|
||||
# /usr/lib/node_modules (which is owned by root).
|
||||
npm_packages = manifest.get("packages", {}).get("npm_global", [])
|
||||
if npm_packages:
|
||||
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
|
||||
npm_prefix = f"{home_dir}/.npm-global"
|
||||
lines.append(
|
||||
f"RUN mkdir -p {npm_prefix} && "
|
||||
f"npm install -g --prefix {npm_prefix} {pkg_list}"
|
||||
)
|
||||
lines.append(f"ENV PATH={npm_prefix}/bin:$PATH")
|
||||
lines.append("")
|
||||
|
||||
# Build scripts
|
||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||
for script in build_scripts:
|
||||
@@ -287,8 +295,6 @@ def compile_entrypoint(manifest: dict) -> str:
|
||||
|
||||
user = manifest.get("user")
|
||||
home_dir = get_manifest_home_dir(manifest)
|
||||
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
|
||||
workspace_target = f"{home_dir}/{workspace_name}"
|
||||
|
||||
# Permission fixer preamble: run as root when possible, else fall back to
|
||||
# passwordless sudo configured in the Dockerfile.
|
||||
@@ -315,7 +321,8 @@ def compile_entrypoint(manifest: dict) -> str:
|
||||
lines.append(f"USER_UID='{uid}'")
|
||||
lines.append(f"USER_GID='{gid}'")
|
||||
lines.append(f"HOME_DIR='{home_dir}'")
|
||||
lines.append(f"WORKSPACE_TARGET='{workspace_target}'")
|
||||
lines.append('WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"')
|
||||
lines.append('WORKSPACE_TARGET="${HOME_DIR}/${WORKSPACE_NAME}"')
|
||||
lines.append("")
|
||||
lines.append("fix_owner() {")
|
||||
lines.append(" local path=\"$1\"")
|
||||
@@ -343,8 +350,13 @@ def compile_entrypoint(manifest: dict) -> str:
|
||||
target = mount.get("target")
|
||||
if not target:
|
||||
continue
|
||||
# Expand any ~/$HOME placeholders in the mount target.
|
||||
expanded = target.replace("~", home_dir).replace("$HOME", home_dir)
|
||||
# Expand any ~/$HOME placeholders and the runtime workspace name
|
||||
# in the mount target so ownership is fixed at container startup.
|
||||
expanded = (
|
||||
target.replace("~", home_dir)
|
||||
.replace("$HOME", home_dir)
|
||||
.replace("{{WORKSPACE_NAME}}", "${WORKSPACE_NAME}")
|
||||
)
|
||||
if expanded.startswith(home_dir) and not mount.get("readonly", False):
|
||||
lines.append(f'fix_owner "{expanded}"')
|
||||
lines.append("")
|
||||
@@ -409,6 +421,12 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
if env:
|
||||
service["environment"] = dict(env)
|
||||
|
||||
# Expose the workspace/repo name so the entrypoint can finalize the
|
||||
# /workspace compatibility symlink at container startup.
|
||||
if "environment" not in service:
|
||||
service["environment"] = {}
|
||||
service["environment"]["WORKSPACE_NAME"] = workspace_name
|
||||
|
||||
# Merge extra env from config
|
||||
extra_env = variables.get("EXTRA_ENV", {})
|
||||
if extra_env:
|
||||
@@ -426,6 +444,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||
if mount.get("source_type") == "repo":
|
||||
has_explicit_repo_mount = True
|
||||
target = expand_container_path(mount["target"], home_dir)
|
||||
target = target.replace("{{WORKSPACE_NAME}}", workspace_name)
|
||||
readonly = ":ro" if mount.get("readonly", False) else ""
|
||||
volumes.append(f"{source}:{target}{readonly}")
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
dir: apps/api/src/services/tool
|
||||
|
||||
## role
|
||||
Orchestrates end-to-end deployment and runtime management of development tool instances via containerized environments with remote access capabilities.
|
||||
Provides containerized execution environment for tools by managing Docker instances, git repositories, and compose orchestration.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
## children
|
||||
-
|
||||
- apps/api/src/services/tool/.ruff_cache
|
||||
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
|
||||
map: apps/api/src/services/tool/.ruff_cache/.pi-map.md
|
||||
## files
|
||||
- instance_service.py
|
||||
## links
|
||||
@@ -16,5 +18,7 @@ map: apps/api/src/services/tool/.pi-map.md
|
||||
## workflows
|
||||
- change tool behavior
|
||||
read: instance_service.py
|
||||
- explore tool subdirectories
|
||||
index: apps/api/src/services/tool/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -880,12 +880,15 @@ async def prepare_manifest_instance(
|
||||
# The actual resolution happens in resolve_git_mounts; we store placeholder
|
||||
git_mount_vars[f"GIT_MOUNT_{ref}"] = ""
|
||||
|
||||
repo_name = os.path.basename(os.path.normpath(repo_path))
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance.name.lower(),
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
"WORKSPACE_PATH": repo_path,
|
||||
"REPO_PATH": repo_path,
|
||||
"REPO_NAME": repo_name,
|
||||
"WORKSPACE_NAME": repo_name,
|
||||
"SSH_PATH": ssh_path,
|
||||
"TOOL_PORT": instance.port or 0,
|
||||
"EXTRA_ENV": env_vars,
|
||||
@@ -1070,12 +1073,15 @@ async def create_tool_instance(
|
||||
|
||||
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
|
||||
# deprecated alias for backward compatibility with older templates.
|
||||
repo_name = os.path.basename(os.path.normpath(repo_path))
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance_name.lower(),
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
"WORKSPACE_PATH": repo_path,
|
||||
"REPO_PATH": repo_path,
|
||||
"REPO_NAME": repo_name,
|
||||
"WORKSPACE_NAME": repo_name,
|
||||
"SSH_PATH": "",
|
||||
"TOOL_PORT": tool_port,
|
||||
"EXTRA_ENV": {},
|
||||
|
||||
Reference in New Issue
Block a user