fix: stage config-profile mounts as directories so ~/.config stays writable

- Switch apply_resolved_profile from per-file bind mounts to one
  directory-level bind mount per ResolvedMount target.
- Stage all configured files under instance_dir/mounts/<sanitized_target>
  and bind-mount that directory, so Docker no longer creates a root-owned
  parent directory such as ~/.config.
- Propagate read-only mode ('ro') as the 'readonly' flag on volume entries.
- Update unit tests to expect directory-level mounts and add coverage for
  readonly/writable flags.

Quality gates: python3 -m py_compile, pytest (313 passed, 34 skipped),
npm run typecheck, npm run lint.
This commit is contained in:
Developer
2026-06-13 11:56:48 +00:00
parent d78ca8a9d5
commit d395aaf574
21 changed files with 206 additions and 53 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides.
## role
A self-hosted, containerized web platform for managing software projects, Git repositories, and development tools with OAuth2/SSO authentication.
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling.
## parent
-
## children
+2 -2
View File
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides.
## role
A self-hosted, containerized web platform for managing software projects, Git repositories, and development tools with OAuth2/SSO authentication.
Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling.
## files
- .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment
- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git
@@ -31,7 +31,7 @@ A self-hosted, containerized web platform for managing software projects, Git re
- progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases
- swap-pane | Empty file with no functionality
## arch
Multi-service Docker Compose architecture with separate PostgreSQL, Redis, API backend, and web frontend services, deployed behind Traefik reverse proxy with TLS, using environment-based configuration and Make-driven lifecycle management.
Containerized microservices architecture using Docker Compose with PostgreSQL/Redis data layer, Traefik reverse proxy for TLS/ingress, multi-stage build separation of API/backend and web frontend, and environment-driven configuration management.
## tags
docker, redis, git, application, postgresql, compose, traefik, project
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps
## role
This directory serves as the container for individual application modules or deployable units within the project.
Contains the main deployable application entry points and top-level configuration for the project.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps
index: apps/.pi-map.index.md
## role
This directory serves as the container for individual application modules or deployable units within the project.
Contains the main deployable application entry points and top-level configuration for the project.
## files
## arch
Monorepo or multi-package workspace pattern where each subdirectory represents a distinct, independently deployable application sharing common infrastructure or libraries.
Monorepo-style directory structure with separate application modules, typically following a microservices or modular monolith pattern where each sub-directory represents an independent runnable service or app.
## tags
-
## symbols
+1 -1
View File
@@ -14,7 +14,7 @@ Self-hosted FastAPI backend API that manages projects, git repositories, and dev
- uv.lock | Lock file for the uv Python package manager that pins exact dependency versions and their artifact hashes for reproducible installations | dep: uv, Python 3.11+, aiosqlite, alembic, annotated-doc, annotated-types, anyio, ast-serialize, asyncpg, and many other PyPI packages
- wait-for-db.sh | Wait for a PostgreSQL database to become available before executing a command, with configurable retry logic. | dep: nc (netcat), sh (POSIX shell), sleep
## arch
Async Python/FastAPI with PostgreSQL (Alembic migrations), multi-stage Docker containerization with socket access, uv package management, and Cloudflare tunneling integration.
Async Python/FastAPI backend with PostgreSQL (asyncpg), Alembic migrations, multi-stage Docker deployment with Cloudflare tunneling, uv package management, and containerized orchestration with database dependency waiting.
## tags
docker, alembic, python, database, fastapi, postgresql, asyncpg, uvicorn
## symbols
+2 -2
View File
@@ -4,7 +4,7 @@ dir: apps/api/src
index: apps/api/src/.pi-map.index.md
## role
Core application package for the Headquarter API, handling configuration, database connectivity, structured logging, and FastAPI application initialization.
Core application package for the Headquarter API, providing configuration, database connectivity, structured logging, and FastAPI application initialization.
## files
- __init__.py | Marks the directory as a Python package for the Headquarter API.
- config.py | Defines application configuration settings with environment-based overrides using Pydantic, including database URLs, service domains, OAuth/Authentik integration, JWT/session settings, and computed properties for environment-specific behavior. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
@@ -12,7 +12,7 @@ Core application package for the Headquarter API, handling configuration, databa
- logging_config.py | Configures structured JSON logging with correlation ID injection, custom formatters, and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation
- main.py | Initializes and configures a FastAPI application for the "Headquarter API" with database setup, middleware, routing, and background services. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
## arch
Layered architecture with environment-based Pydantic config, async SQLAlchemy with retry patterns, structured JSON logging with correlation IDs, and modular FastAPI setup with middleware and background services.
Layered architecture with environment-based Pydantic config, async SQLAlchemy with retry patterns, structured JSON logging with correlation ID tracking, and modular FastAPI setup with middleware pipeline.
## tags
src, database, logging, call:logger.info, api, middleware, fastapi, filter
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services
## role
This directory is intended to contain service-layer business logic modules for the API application.
Provides a Python package namespace for organizing service-layer modules in the API application.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/src/services
index: apps/api/src/services/.pi-map.index.md
## role
This directory is intended to contain service-layer business logic modules for the API application.
Provides a Python package namespace for organizing service-layer modules in the API application.
## files
- __init__.py | Empty file with no functionality
## arch
Empty package placeholder following standard Python package structure with no implemented architecture yet.
Standard Python package structure using __init__.py for directory-based module organization, following conventional layered architecture patterns.
## tags
init, empty, functionality
## symbols
@@ -2,7 +2,7 @@
dir: apps/api/src/services/config
## role
Provides configuration profile resolution and management services for the API, handling hierarchical config merging, validation, and access control.
Provides configuration profile management with recursive resolution, validation, and access control for container environment setup.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
+4 -4
View File
@@ -4,16 +4,16 @@ dir: apps/api/src/services/config
index: apps/api/src/services/config/.pi-map.index.md
## role
Provides configuration profile resolution and management services for the API, handling hierarchical config merging, validation, and access control.
Provides configuration profile management with recursive resolution, validation, and access control for container environment setup.
## files
- __init__.py | Re-exports configuration profile resolution components from a subpackage to provide a unified public API. | dep: src.services.config.config_profile_resolver
- config_profile_resolver.py | Resolves configuration profiles recursively with ordered include merging, cycle detection, and deterministic override rules for environment variables, runtime hints, files, mounts, and git mounts. | exp: class:ConfigProfileCycleError, class:ConfigProfileNotFoundError, class:ResolvedMount, class:ResolvedProfile, func:_detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → bool, func:_merge_env_vars(base: dict[str, str], overlay: dict[str, str], overrides: dict[str, str], source_name: str) → dict[str, str], call:dict, call:overlay.items, func:_merge_runtime_hints(base: dict[str, Any], overlay: dict[str, Any], overrides: dict[str, str], source_name: str) → dict[str, Any], call:dict, call:overlay.items, func:_merge_files(base: dict[str, str], overlay: dict[str, str], overrides: dict[str, str], source_name: str) → dict[str, str], call:dict, call:overlay.items, func:_merge_mounts(base: dict[str, ResolvedMount], overlay: list[dict[str, Any]], overrides: dict[str, str], source_name: str) → dict[str, ResolvedMount], call:dict, call:mount_data.get, call:files.items, call:ResolvedMount, func:_merge_git_mounts(base: list[dict[str, Any]], overlay: list[dict[str, Any]], source_name: str) → list[dict[str, Any]], call:list, call:enumerate, call:_normalize_git_mount_entry, call:dict, call:m.get, call:mount.get, call:existing.get, call:existing["mappings"].append, call:existing_sources.add, call:len, call:result.append, func:_normalize_git_mount_entry(entry: dict[str, Any]) → dict[str, Any], call:dict, call:entry.get, call:entry.pop, func:_resolve_profile_recursive(session: AsyncSession, profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → ResolvedProfile, call:_detect_cycle, call:" -> ".join, call:str, call:session.get, call:ResolvedProfile, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == profile_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_resolve_profile_recursive, call:result.included_profiles.append, call:_merge_env_vars, call:_merge_runtime_hints, call:_merge_files, call:_merge_mounts, call:included.mounts.values, call:_merge_git_mounts, raise:ConfigProfileCycleError, raise:ConfigProfileNotFoundError, func:resolve_profile(session: AsyncSession, profile_id: uuid.UUID) → ResolvedProfile, call:_resolve_profile_recursive, call:set, func:check_include_cycle(session: AsyncSession, profile_id: uuid.UUID, new_include_id) → list[uuid.UUID] | None, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == current_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_check_from, call:set, call:len, func:_check_from(current_id: uuid.UUID, target_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → list[uuid.UUID] | None, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == current_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_check_from, func:apply_resolved_profile(instance_dir: str, resolved: ResolvedProfile, home_dir) → tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]], call:Path, call:dict, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:logger.warning, call:full_path.parent.mkdir, call:full_path.write_text, call:resolved.mounts.values, call:expand_container_path, call:expanded_target.lstrip("/").replace, call:mount_dir.mkdir, call:mount.files.items, call:mount_dir.resolve, call:os.path.join, call:volume_mounts.append, call:str, func:expand_container_path(path: str, home_dir: str) → str, call:path.startswith, call:os.path.join, func:resolved_profile_to_dict(resolved: ResolvedProfile) → dict[str, Any], call:str, call:resolved.mounts.values | dep: logging, os, uuid, dataclasses, typing, sqlalchemy, sqlalchemy.ext.asyncio, src.models, pathlib
- config_profile_resolver.py | Resolves configuration profiles recursively with ordered includes, deterministic merge rules, and cycle protection for container environment setup. | exp: class:ConfigProfileCycleError, class:ConfigProfileNotFoundError, class:ResolvedMount, class:ResolvedProfile, func:_detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → bool, func:_merge_env_vars(base: dict[str, str], overlay: dict[str, str], overrides: dict[str, str], source_name: str) → dict[str, str], call:dict, call:overlay.items, func:_merge_runtime_hints(base: dict[str, Any], overlay: dict[str, Any], overrides: dict[str, str], source_name: str) → dict[str, Any], call:dict, call:overlay.items, func:_merge_files(base: dict[str, str], overlay: dict[str, str], overrides: dict[str, str], source_name: str) → dict[str, str], call:dict, call:overlay.items, func:_merge_mounts(base: dict[str, ResolvedMount], overlay: list[dict[str, Any]], overrides: dict[str, str], source_name: str) → dict[str, ResolvedMount], call:dict, call:mount_data.get, call:files.items, call:ResolvedMount, func:_merge_git_mounts(base: list[dict[str, Any]], overlay: list[dict[str, Any]], source_name: str) → list[dict[str, Any]], call:list, call:enumerate, call:_normalize_git_mount_entry, call:dict, call:m.get, call:mount.get, call:existing.get, call:existing["mappings"].append, call:existing_sources.add, call:len, call:result.append, func:_normalize_git_mount_entry(entry: dict[str, Any]) → dict[str, Any], call:dict, call:entry.get, call:entry.pop, func:_resolve_profile_recursive(session: AsyncSession, profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → ResolvedProfile, call:_detect_cycle, call:" -> ".join, call:str, call:session.get, call:ResolvedProfile, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == profile_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_resolve_profile_recursive, call:result.included_profiles.append, call:_merge_env_vars, call:_merge_runtime_hints, call:_merge_files, call:_merge_mounts, call:included.mounts.values, call:_merge_git_mounts, raise:ConfigProfileCycleError, raise:ConfigProfileNotFoundError, func:resolve_profile(session: AsyncSession, profile_id: uuid.UUID) → ResolvedProfile, call:_resolve_profile_recursive, call:set, func:check_include_cycle(session: AsyncSession, profile_id: uuid.UUID, new_include_id) → list[uuid.UUID] | None, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == current_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_check_from, call:set, call:len, func:_check_from(current_id: uuid.UUID, target_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) → list[uuid.UUID] | None, call:select(ConfigProfileInclude) .where(ConfigProfileInclude.profile_id == current_id) .order_by, call:session.execute, call:include_result.scalars().all, call:_check_from, func:apply_resolved_profile(instance_dir: str, resolved: ResolvedProfile, home_dir) → tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]], call:Path, call:dict, call:files.items, call:full_path.resolve().relative_to, call:instance_path.resolve, call:logger.warning, call:full_path.parent.mkdir, call:full_path.write_text, call:resolved.mounts.values, call:os.path.normpath, call:expand_container_path, call:expanded_target.lstrip("/").replace, call:mount_dir.mkdir, call:mount.files.items, call:mount_dir.resolve, call:volume_mounts.append, call:str, func:expand_container_path(path: str, home_dir: str) → str, call:path.startswith, call:os.path.join, func:resolved_profile_to_dict(resolved: ResolvedProfile) → dict[str, Any], call:str, call:resolved.mounts.values | dep: logging, os, uuid, dataclasses, typing, sqlalchemy, sqlalchemy.ext.asyncio, src.models, pathlib
- crud_service.py | Provides CRUD operations for configuration profiles including validation, access control, size limits, and include cycle detection. | exp: func:calculate_profile_size(data: dict) → int, call:data.get("env_vars", {}).items, call:len, call:key.encode, call:str(value).encode, call:data.get("runtime_hints", {}).items, call:str(mount.get("target", "")).encode, call:str(mount.get("mode", "")).encode, call:mount.get("files", {}).items, call:path.encode, call:content.encode, call:data.get("files", {}).items, func:get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) → ConfigProfile | None, call:session.execute, call:select(ConfigProfile) .where(ConfigProfile.id == profile_id) .options, call:selectinload, call:result.scalar_one_or_none, func:check_access(session: AsyncSession, user_id: uuid.UUID, project_id, tool_type_id) → None, call:session.get, raise:HTTPException, func:validate_git_mounts(session: AsyncSession, user_id: uuid.UUID, git_mounts: list[Any], project_id) → None, call:mount.get, call:remote_url.startswith, raise:HTTPException, func:profile_to_response(profile: ConfigProfile, includes) → dict, call:str, call:profile.created_at.isoformat, call:profile.updated_at.isoformat, func:get_or_create_user_config(session: AsyncSession, user_id: uuid.UUID) → UserConfig, call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:UserConfig, call:session.add, func:validate_default_profiles(session: AsyncSession, user_id: uuid.UUID, default_profiles: dict[str, str]) → None, call:default_profiles.items, call:uuid.UUID, call:session.get, raise:HTTPException, func:create_profile(session: AsyncSession, user_id: uuid.UUID, data: Any) → ConfigProfile, call:session.execute, call:select(ConfigProfile) .where( ConfigProfile.user_id == user_id, ConfigProfile.name == data.name, ) .options, call:selectinload, call:existing.scalar_one_or_none, call:uuid.UUID, call:check_access, call:m.model_dump, call:hasattr, call:validate_git_mounts, call:calculate_profile_size, call:data.model_dump, call:ConfigProfile, call:session.add, call:session.commit, call:select(ConfigProfile) .where(ConfigProfile.id == profile.id) .options, call:result.scalar_one, raise:HTTPException, func:update_profile(session: AsyncSession, profile: ConfigProfile, data: Any) → ConfigProfile, call:data.model_dump, call:session.execute, call:select(ConfigProfile).where, call:existing.scalar_one_or_none, call:uuid.UUID, call:check_access, call:m.model_dump, call:hasattr, call:validate_git_mounts, call:profile_to_response, call:calculate_profile_size, call:update_data.items, call:isinstance, call:setattr, call:session.commit, call:select(ConfigProfile) .where(ConfigProfile.id == profile.id) .options, call:selectinload, call:result.scalar_one, raise:HTTPException, func:update_includes(session: AsyncSession, profile: ConfigProfile, included_ids: list[uuid.UUID], user_id: uuid.UUID) → ConfigProfile, call:session.get, call:check_include_cycle, call:" -> ".join, call:str, call:session.execute, call:select(ConfigProfileInclude).where, call:result.scalars().all, call:session.delete, call:session.flush, call:enumerate, call:ConfigProfileInclude, call:session.add, call:session.commit, call:select(ConfigProfile).where, call:result.scalar_one, raise:HTTPException | dep: uuid, typing, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, sqlalchemy.orm, src.models, src.models.project, src.services.config.config_profile_resolver
- resolver_service.py | Resolves default configuration profiles for users/projects/tools and validates Git URLs with SSH key support. | exp: func:resolve_default_profile(session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, tool_type_id: uuid.UUID) → dict, call:select(ConfigProfile) .where(ConfigProfile.user_id == user_id) .where( (ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None)) | (ConfigProfile.project_id == project_id) | (ConfigProfile.tool_type_id == tool_type_id) | ( (ConfigProfile.project_id == project_id) & (ConfigProfile.tool_type_id == tool_type_id) ) ) .order_by, call:ConfigProfile.project_id.is_, call:ConfigProfile.tool_type_id.is_, call:session.execute, call:result.scalars().all, call:str, func:validate_git_url(session: AsyncSession, current_user_id: uuid.UUID, url: str, ssh_key_id: str | None) → ValidateGitUrlResponse, call:parse_git_url, call:url.strip, call:parse_result.get, call:ValidateGitUrlResponse, call:uuid.UUID, call:session.get, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:tempfile.mkstemp, call:os.write, call:private_key.encode, call:os.close, call:os.chmod, call:subprocess.run, call:os.path.exists, call:os.unlink, call:result.stderr.strip, call:stderr.lower, call:result.stdout.strip().split, call:line.strip, call:line.split, call:len, call:ref.startswith, call:branches.append | dep: logging, os, subprocess, uuid, typing, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.models, src.services.shared.ssh_keys, src.utils.git_url_parser, src.schemas.config, tempfile
## arch
Service-layer pattern with separation of concerns: dedicated resolver (recursive merge with cycle detection), CRUD operations (validation/ACLs), and default resolution (user/project/tool scoping); supports multiple config sources with deterministic override precedence.
Layered service architecture with separation between resolution logic (recursive merge with cycle detection), CRUD operations (validation, access control, size limits), and default resolution (Git/SSH integration), exposed through a unified facade pattern via __init__.py.
## tags
profile, call:, call:select, git, merge, call:str, call:session.execute, mounts
profile, call:, call:select, merge, git, call:str, call:session.execute, cycle
## symbols
- ConfigProfileCycleError
- ConfigProfileNotFoundError
@@ -476,9 +476,19 @@ def apply_resolved_profile(
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Stage mount files and prepare volume mounts
# Stage mount directories and prepare directory-level volume mounts.
# Each ResolvedMount targets a container directory; we stage all of its
# files under a single host directory and bind-mount that directory. This
# keeps the target directory writable by the container user, instead of
# having Docker create a root-owned parent directory when only individual
# files are mounted.
for mount in resolved.mounts.values():
expanded_target = expand_container_path(mount.target, home_dir)
if not mount.files:
continue
expanded_target = os.path.normpath(
expand_container_path(mount.target, home_dir)
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
@@ -494,16 +504,14 @@ def apply_resolved_profile(
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Mount each file individually so sibling files from other mounts
# (e.g. git repo directories) are preserved.
file_target = os.path.join(expanded_target, file_path)
volume_mounts.append(
{
"source": str(full_path),
"target": file_target,
"type": "bind",
}
)
volume_mounts.append(
{
"source": str(mount_dir),
"target": expanded_target,
"type": "bind",
"readonly": mount.mode in ("ro", "readonly"),
}
)
return env_vars, files, volume_mounts, resolved.runtime_hints
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests
## role
Provides shared test infrastructure and fixtures for API integration testing.
Provides shared test infrastructure and fixtures for the FastAPI API application.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: apps/api/tests
index: apps/api/tests/.pi-map.index.md
## role
Provides shared test infrastructure and fixtures for API integration testing.
Provides shared test infrastructure and fixtures for the FastAPI API application.
## files
- conftest.py | Provides shared pytest fixtures for testing a FastAPI application with async SQLite database, authenticated clients, and test data setup. | exp: func:test_client() → Generator[TestClient, None, None], call:create_async_engine, call:engine.begin, call:conn.run_sync, call:asyncio.run, call:init_db, call:async_sessionmaker, call:patch, call:TestClient, call:app.dependency_overrides.pop, call:engine.dispose, func:init_db(), call:engine.begin, call:conn.run_sync, func:override_get_db_session() → AsyncGenerator[AsyncSession, None], call:async_sessionmaker, func:db_session(test_client) → AsyncGenerator[AsyncSession, None], call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:gen.aclose, call:create_async_engine, call:engine.begin, call:conn.run_sync, call:async_sessionmaker, call:engine.dispose, func:authenticated_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_test_user, call:create_session_cookie, call:test_client.cookies.set, func:create_test_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, func:test_project_and_repo(authenticated_client) → tuple[str, str], call:uuid.uuid4, call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, call:asyncio.run, call:get_user_id, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, call:create_project_and_repo, call:str, raise:RuntimeError, func:get_user_id(), call:Settings, call:authenticated_client.cookies.get, call:decode_session_cookie, call:uuid.UUID, func:create_project_and_repo(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:Project, call:session.add, call:GitRepository, call:session.commit, call:gen.aclose, func:admin_client(test_client) → Generator[TestClient, None, None], call:str, call:uuid.uuid4, call:Settings, call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose, call:asyncio.run, call:create_admin_user, call:create_session_cookie, call:test_client.cookies.set, func:create_admin_user(), call:app.dependency_overrides.get, call:override_fn, call:gen.asend, call:User, call:uuid.UUID, call:session.add, call:session.commit, call:gen.aclose | dep: asyncio, os, typing, unittest.mock, pytest, pytest_asyncio, fastapi.testclient, sqlalchemy.ext.asyncio, src.config, src.models.base, src.main, src.auth.dependencies, uuid, src.auth.session, src.models.user.user, src.models.project.project, src.models.project.git_repository, fastapi, sqlalchemy, aiosqlite, src.models, src.auth
## arch
Pytest fixture-based testing architecture with async SQLite database lifecycle management, FastAPI TestClient dependency injection, and authenticated client wrappers for role-based testing.
Pytest fixture-based testing architecture using async SQLite in-memory database, dependency injection overrides, and async HTTP client setup for isolated integration tests.
## tags
call:app.dependency, call:create, overrides.get, call:override, fn, call:gen.asend, call:gen.aclose, user
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/tests/unit
## role
Contains comprehensive unit tests for the API application's core services, utilities, and infrastructure components.
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
## parent
index: apps/api/tests/.pi-map.index.md
map: apps/api/tests/.pi-map.md
+4 -4
View File
@@ -4,11 +4,11 @@ dir: apps/api/tests/unit
index: apps/api/tests/unit/.pi-map.index.md
## role
Contains comprehensive unit tests for the API application's core services, utilities, and infrastructure components.
Contains comprehensive unit tests for the API backend services covering configuration, Docker operations, Git integration, file management, health monitoring, notifications, and SSH key handling.
## files
- __init__.py | Empty file with no functionality
- test_config.py | Tests configuration settings and database URL building for an application, verifying defaults, environment variable overrides, and environment-specific behavior. | exp: func:test_settings_default_database_url_uses_asyncpg(monkeypatch) → None, call:monkeypatch.delenv, call:Settings, func:test_build_database_url_uses_explicit_values() → None, call:build_database_url, func:test_settings_prefers_explicit_database_url_env(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_auth_settings_have_secure_defaults() → None, call:Settings, call:settings.resolved_authentik_authorize_url.endswith, call:settings.resolved_authentik_token_url.endswith, call:settings.resolved_authentik_jwks_url.endswith, func:test_cookie_policy_is_strict_in_production(monkeypatch) → None, call:monkeypatch.setenv, call:Settings, func:test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) → None, call:monkeypatch.setenv, call:Settings | dep: pytest, src.config, src.database
- test_config_profile_resolver.py | Unit tests for config profile resolution including merge functions, profile inheritance with includes, cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_individual_files_not_directory(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, method:test_file_mount_preserves_sibling_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0]["source"].endswith, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_file_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, class:TestCheckIncludeCycle | dep: uuid, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
- test_config_profile_resolver.py | Tests the config profile resolution system including merge helpers, profile inheritance with cycle detection, and git mount normalization | exp: class:TestMergeFunctions, method:test_merge_env_vars_basic(self) → None, call:_merge_env_vars, method:test_merge_env_vars_tracks_overrides(self) → None, call:_merge_env_vars, method:test_merge_runtime_hints_basic(self) → None, call:_merge_runtime_hints, method:test_merge_files_basic(self) → None, call:_merge_files, method:test_merge_mounts_basic(self) → None, call:_merge_mounts, method:test_merge_mounts_file_override(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_mounts_mode_conflict(self) → None, call:_merge_mounts, call:ResolvedMount, method:test_merge_git_mounts_basic(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_concatenate_same_repo_branch(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_dedup_same_mapping(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_repos(self) → None, call:_merge_git_mounts, call:len, method:test_merge_git_mounts_different_branches(self) → None, call:_merge_git_mounts, call:len, call:m.get, class:TestResolveProfile, class:TestApplyResolvedProfile, method:test_mounts_directory_not_individual_files(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path(volumes[0]["source"]).is_dir, call:(Path(volumes[0]["source"]) / "config.json").exists, call:(Path(volumes[0]["source"]) / "nested" / "file.txt").exists, method:test_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:Path, call:(Path(volumes[0]["source"]) / "z.json").exists, method:test_empty_mount_produces_no_volumes(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, method:test_home_expansion_in_directory_mount_target(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:(Path(volumes[0]["source"]) / "app.toml").exists, call:Path, method:test_readonly_mount_sets_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, method:test_writable_mount_does_not_set_readonly_flag(self, tmp_path) → None, call:ResolvedProfile, call:uuid.uuid4, call:ResolvedMount, call:apply_resolved_profile, call:str, call:len, call:volumes[0].get, class:TestCheckIncludeCycle | dep: uuid, pathlib, pytest, sqlalchemy.ext.asyncio, src.models.config.config_profile, src.services.config.config_profile_resolver
- test_docker_build.py | Unit tests for a Docker image build service that verifies Dockerfile creation, command structure, context file handling, path traversal prevention, and error handling. | exp: class:TestBuildImage | dep: subprocess, tempfile, pathlib, unittest.mock, pytest, src.services.build.docker_build
- test_docker_service.py | Unit tests for Docker service utilities including container ID/name retrieval and volume sorting by specificity. | exp: class:TestGetContainerId, class:TestGetContainerName, class:TestSortVolumesBySpecificity, method:test_parent_before_child(self) → None, call:sort_volumes_by_specificity, method:test_stable_sort_for_equal_depth(self) → None, call:sort_volumes_by_specificity, method:test_with_type_suffix(self) → None, call:sort_volumes_by_specificity, method:test_empty_list(self) → None, call:sort_volumes_by_specificity, method:test_single_volume(self) → None, call:sort_volumes_by_specificity, method:test_duplicate_target_warning(self, caplog) → None, call:caplog.at_level, call:sort_volumes_by_specificity | dep: unittest.mock, logging, src.services.docker.container, src.services.docker.compose, subprocess
- test_event_bus.py | Unit tests for InstanceEventBus verifying publish/subscribe, exception isolation, unsubscribe, and async callback support. | exp: func:event_bus() → InstanceEventBus, call:InstanceEventBus, call:bus._reset_for_testing, func:sample_payload() → InstanceEventPayload, call:str, call:uuid.uuid4, func:test_publish_delivers_to_all_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, call:len, func:callback_1(payload: InstanceEventPayload) → None, call:received.append, func:callback_2(payload: InstanceEventPayload) → None, call:received.append, func:callback_3(payload: InstanceEventPayload) → None, call:received.append, func:test_subscriber_exception_isolation(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.publish, raise:RuntimeError, func:bad_callback(_payload: InstanceEventPayload) → None, raise:RuntimeError, func:good_callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_unsubscribe_removes_callback(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:unsubscribe, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append, func:test_publish_to_empty_subscriber_list(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:event_bus.publish, func:test_async_subscriber_supported(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, call:event_bus.subscribe, call:event_bus.publish, func:async_callback(_payload: InstanceEventPayload) → None, call:asyncio.sleep, call:received.append, func:test_unsubscribe_all_clears_subscribers(event_bus: InstanceEventBus, sample_payload: InstanceEventPayload) → None, call:received.append, call:event_bus.subscribe, call:event_bus.unsubscribe_all, call:event_bus.publish, func:callback(_payload: InstanceEventPayload) → None, call:received.append | dep: asyncio, uuid, typing, pytest, src.services.instance.event_bus
@@ -28,9 +28,9 @@ Contains comprehensive unit tests for the API application's core services, utili
- test_readiness_probe.py | Unit tests for a Docker container readiness probe service that executes commands via docker exec with retry logic. | exp: class:TestExecuteProbe, class:TestIntegrationScenarios | dep: unittest.mock, src.services.shared.readiness_probe, subprocess
- test_ssh_keys.py | Unit tests for SSH key preparation functionality including file creation, permissions, ownership, and error handling | exp: class:TestPrepareSshKeyFiles | dep: os, pathlib, unittest.mock, pytest, src.services.shared.ssh_keys
## arch
Standard Python unittest/pytest structure with heavy use of mocking for external dependencies (Docker, Git subprocess, database), testing business logic in isolation without integration requirements.
Standard Python unittest/pytest pattern with heavy mocking of external dependencies (subprocess, docker, filesystem) to test service layer logic in isolation, organized by functional domain with one test module per service component.
## tags
test, url, call:notification, git, call:db, merge, call:, src
test, url, call:notification, git, call:, call:db, merge, src
## symbols
- TestMergeFunctions
- TestResolveProfile
@@ -1,4 +1,6 @@
import uuid
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@@ -483,10 +485,10 @@ class TestResolveProfile:
class TestApplyResolvedProfile:
"""Unit tests for apply_resolved_profile file-level mount behavior."""
"""Unit tests for apply_resolved_profile directory-level mount behavior."""
def test_mounts_individual_files_not_directory(self, tmp_path) -> None:
"""Each file in a ResolvedMount should be mounted individually, not the staging dir."""
def test_mounts_directory_not_individual_files(self, tmp_path) -> None:
"""Each ResolvedMount should produce one directory-level bind mount."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -503,15 +505,15 @@ class TestApplyResolvedProfile:
)
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 2
targets = {v["target"] for v in volumes}
assert "/app/config.json" in targets
assert "/app/nested/file.txt" in targets
# No directory-level mount
assert "/app" not in targets
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert Path(volumes[0]["source"]).name == "app"
assert Path(volumes[0]["source"]).is_dir()
assert (Path(volumes[0]["source"]) / "config.json").exists()
assert (Path(volumes[0]["source"]) / "nested" / "file.txt").exists()
def test_file_mount_preserves_sibling_files(self, tmp_path) -> None:
"""File-level mounts should not hide sibling files from other mounts."""
def test_directory_mount_target(self, tmp_path) -> None:
"""A directory-level mount targets the configured directory path."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -526,8 +528,9 @@ class TestApplyResolvedProfile:
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 1
assert volumes[0]["target"] == "/workspace/x/y/z.json"
assert volumes[0]["source"].endswith("z.json")
assert volumes[0]["target"] == "/workspace/x/y"
assert Path(volumes[0]["source"]).name == "workspace_x_y"
assert (Path(volumes[0]["source"]) / "z.json").exists()
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
"""A mount with no files should not produce any volume entries."""
@@ -539,8 +542,8 @@ class TestApplyResolvedProfile:
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert volumes == []
def test_home_expansion_in_file_mount_target(self, tmp_path) -> None:
"""~ in mount target should be expanded to home_dir for file mounts."""
def test_home_expansion_in_directory_mount_target(self, tmp_path) -> None:
"""~ in mount target should be expanded to home_dir for directory mounts."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -555,7 +558,47 @@ class TestApplyResolvedProfile:
env, files, volumes, hints = apply_resolved_profile(
str(tmp_path), resolved, home_dir="/home/user"
)
assert volumes[0]["target"] == "/home/user/.config/app.toml"
assert len(volumes) == 1
assert volumes[0]["target"] == "/home/user/.config"
assert (Path(volumes[0]["source"]) / "app.toml").exists()
def test_readonly_mount_sets_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'ro' should set readonly on the volume entry."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
mounts={
"/etc/app": ResolvedMount(
target="/etc/app",
mode="ro",
files={"config.cfg": "value"},
)
},
)
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 1
assert volumes[0]["target"] == "/etc/app"
assert volumes[0].get("readonly") is True
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'rw' should not set readonly on the volume entry."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
mounts={
"/app": ResolvedMount(
target="/app",
mode="rw",
files={"config.json": "{}"},
)
},
)
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False
class TestCheckIncludeCycle:
@@ -0,0 +1,4 @@
name: config-profile-directory-mounts
status: completed
completed_at: 2026-06-13
started_at: 2026-06-13
@@ -0,0 +1,34 @@
# . (index)
dir: .
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
Provides configuration and documentation for a mount staging system that resolves file ownership issues in containerized environments.
## parent
-
## children
-
## files
- .openspec.yaml
- proposal.md
- tasks.md
## links
index: ./.pi-map.index.md
map: ./.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,35 @@
# .
dir: .
index: ./.pi-map.index.md
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
Provides configuration and documentation for a mount staging system that resolves file ownership issues in containerized environments.
## files
- .openspec.yaml | Defines an OpenSpec configuration profile for directory mounts with completion tracking metadata
- proposal.md | Proposes a design change to fix root-owned directory issues in config profile mounts by staging and bind-mounting entire directories instead of individual files. | dep: Docker, config_profile_resolver.py, test_config_profile_resolver.py
- tasks.md | Tracks completed development tasks for refactoring a mount staging system and running quality gates.
## arch
Configuration-driven design using YAML profiles for directory mounts, with a proposal-based development approach documenting a shift from file-level to directory-level bind-mount staging.
## tags
tasks, profile, directory, mounts, .openspec, staging, py, proposal
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,21 @@
# Config profile directory mounts
## Problem
`apply_resolved_profile` currently stages each config-profile mount file individually and bind-mounts each file into the container. Docker creates the parent directory (for example `~/.config`) as root-owned when only files are mounted, so applications running as a non-root container user cannot write new files inside that directory.
## Decision
Stage a directory per mount target and bind-mount the whole directory. The staged directory is created under `instance_dir/mounts/` and is chowned to the resolved container user before the container starts, so the container user can create and modify files inside the target directory.
## Scope
- Change `apply_resolved_profile` in `apps/api/src/services/config/config_profile_resolver.py` to emit one directory-level volume mount per `ResolvedMount` target.
- Continue staging all configured files under that directory.
- Preserve read-only mode by setting `readonly: true` on the volume entry when `mode` is `ro`.
- Update unit tests in `apps/api/tests/unit/test_config_profile_resolver.py` to expect directory-level mounts.
## Non-goals
- No changes to git-mount, SSH-key, workspace, or manifest mount handling.
- No changes to config profile storage or resolution semantics.
@@ -0,0 +1,8 @@
# Tasks
- [x] Identify the per-file mount staging in `apply_resolved_profile` as the root cause.
- [x] Refactor `apply_resolved_profile` to stage one directory per mount target and bind-mount the directory.
- [x] Propagate read-only mode to the volume entry.
- [x] Update `TestApplyResolvedProfile` unit tests to assert directory-level mounts.
- [x] Run backend quality gates (`py_compile`, `pytest`).
- [x] Commit, merge to `dev`, push.