diff --git a/.pi-map.index.md b/.pi-map.index.md index a736a28..d828baa 100644 --- a/.pi-map.index.md +++ b/.pi-map.index.md @@ -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 diff --git a/.pi-map.md b/.pi-map.md index 309c66f..075b604 100644 --- a/.pi-map.md +++ b/.pi-map.md @@ -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 diff --git a/apps/.pi-map.index.md b/apps/.pi-map.index.md index 051bf08..fc977f0 100644 --- a/apps/.pi-map.index.md +++ b/apps/.pi-map.index.md @@ -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 diff --git a/apps/.pi-map.md b/apps/.pi-map.md index 3022259..f37f96d 100644 --- a/apps/.pi-map.md +++ b/apps/.pi-map.md @@ -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 diff --git a/apps/api/.pi-map.md b/apps/api/.pi-map.md index b7c9f53..cdd2c4f 100644 --- a/apps/api/.pi-map.md +++ b/apps/api/.pi-map.md @@ -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 diff --git a/apps/api/src/.pi-map.md b/apps/api/src/.pi-map.md index 582bffa..d016057 100644 --- a/apps/api/src/.pi-map.md +++ b/apps/api/src/.pi-map.md @@ -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 diff --git a/apps/api/src/services/.pi-map.index.md b/apps/api/src/services/.pi-map.index.md index 0913da1..3d0b79c 100644 --- a/apps/api/src/services/.pi-map.index.md +++ b/apps/api/src/services/.pi-map.index.md @@ -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 diff --git a/apps/api/src/services/.pi-map.md b/apps/api/src/services/.pi-map.md index 5091c10..d7e5d8c 100644 --- a/apps/api/src/services/.pi-map.md +++ b/apps/api/src/services/.pi-map.md @@ -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 diff --git a/apps/api/src/services/config/.pi-map.index.md b/apps/api/src/services/config/.pi-map.index.md index 36adb4e..2133418 100644 --- a/apps/api/src/services/config/.pi-map.index.md +++ b/apps/api/src/services/config/.pi-map.index.md @@ -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 diff --git a/apps/api/src/services/config/.pi-map.md b/apps/api/src/services/config/.pi-map.md index 4d29721..210a4ac 100644 --- a/apps/api/src/services/config/.pi-map.md +++ b/apps/api/src/services/config/.pi-map.md @@ -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 diff --git a/apps/api/src/services/config/config_profile_resolver.py b/apps/api/src/services/config/config_profile_resolver.py index 471acb7..83a5ac0 100644 --- a/apps/api/src/services/config/config_profile_resolver.py +++ b/apps/api/src/services/config/config_profile_resolver.py @@ -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 diff --git a/apps/api/tests/.pi-map.index.md b/apps/api/tests/.pi-map.index.md index f7349a3..77f726f 100644 --- a/apps/api/tests/.pi-map.index.md +++ b/apps/api/tests/.pi-map.index.md @@ -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 diff --git a/apps/api/tests/.pi-map.md b/apps/api/tests/.pi-map.md index d81871e..2a35dc9 100644 --- a/apps/api/tests/.pi-map.md +++ b/apps/api/tests/.pi-map.md @@ -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 diff --git a/apps/api/tests/unit/.pi-map.index.md b/apps/api/tests/unit/.pi-map.index.md index e46b1c4..51f736d 100644 --- a/apps/api/tests/unit/.pi-map.index.md +++ b/apps/api/tests/unit/.pi-map.index.md @@ -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 diff --git a/apps/api/tests/unit/.pi-map.md b/apps/api/tests/unit/.pi-map.md index 0b89657..920b089 100644 --- a/apps/api/tests/unit/.pi-map.md +++ b/apps/api/tests/unit/.pi-map.md @@ -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 diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py index 06b74ad..32d92fb 100644 --- a/apps/api/tests/unit/test_config_profile_resolver.py +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -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: diff --git a/openspec/changes/config-profile-directory-mounts/.openspec.yaml b/openspec/changes/config-profile-directory-mounts/.openspec.yaml new file mode 100644 index 0000000..cc36da0 --- /dev/null +++ b/openspec/changes/config-profile-directory-mounts/.openspec.yaml @@ -0,0 +1,4 @@ +name: config-profile-directory-mounts +status: completed +completed_at: 2026-06-13 +started_at: 2026-06-13 diff --git a/openspec/changes/config-profile-directory-mounts/.pi-map.index.md b/openspec/changes/config-profile-directory-mounts/.pi-map.index.md new file mode 100644 index 0000000..d29ca8e --- /dev/null +++ b/openspec/changes/config-profile-directory-mounts/.pi-map.index.md @@ -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 +- diff --git a/openspec/changes/config-profile-directory-mounts/.pi-map.md b/openspec/changes/config-profile-directory-mounts/.pi-map.md new file mode 100644 index 0000000..78f6710 --- /dev/null +++ b/openspec/changes/config-profile-directory-mounts/.pi-map.md @@ -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 +- diff --git a/openspec/changes/config-profile-directory-mounts/proposal.md b/openspec/changes/config-profile-directory-mounts/proposal.md new file mode 100644 index 0000000..f4556ec --- /dev/null +++ b/openspec/changes/config-profile-directory-mounts/proposal.md @@ -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. diff --git a/openspec/changes/config-profile-directory-mounts/tasks.md b/openspec/changes/config-profile-directory-mounts/tasks.md new file mode 100644 index 0000000..0322b43 --- /dev/null +++ b/openspec/changes/config-profile-directory-mounts/tasks.md @@ -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.