Merge branch 'feat/config-profile-directory-mounts' into dev
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
Reference in New Issue
Block a user