The platform's side of D6: git runs on the host, in a gitdir that is never mounted into a container, so repository credentials never reach the model. - adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at /workspace + one linked worktree per task under work/.worktrees/<id> (excluded via the gitdir's info/exclude, not the project's .gitignore). Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off, quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off, one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH key as a 0600 temp file with a pinned known_hosts. Everything deleted in a finally; only a *path* ever travels through the environment. - The bug this phase existed to find: --git-dir=<shared> does not address a linked worktree, so `git commit` in a task worktree committed straight to `dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and never read the worktree's .git file, which a repository can rewrite. - domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed (resolves, refuses if any address is loopback/private/link-local/metadata). Shared by the git remote test and, later, every deploy target. - ProjectExternalGit domain model + repository; container_delivery wires the real adapter behind GIT_WORKSPACE=local with a resolver that joins the project, its repo and its (decrypted) credentials. Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-2.md
127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester).
|
|
|
|
The real adapters are selected by settings; the fakes remain the defaults so a
|
|
test that does not opt in never touches a git binary, a container or a network.
|
|
|
|
``build_repo_workspace`` is the interesting one: the git adapter needs to know
|
|
*where* a project's repository lives and *how* to authenticate to it, and that
|
|
knowledge lives in three places — ``project_repositories`` +
|
|
``project_git_credentials`` for the platform forge, ``project_external_git`` for a
|
|
user-supplied remote, and ``projects`` for which of the two applies. The resolver
|
|
below is the only code that joins them, and it hands the plaintext secret
|
|
straight to the adapter for the duration of a single call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
|
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
|
|
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
|
|
from app.core.config import settings
|
|
from app.core.crypto import decrypt
|
|
from app.infrastructure.adapters.git_workspace import (
|
|
LocalGitWorkspaceAdapter,
|
|
ProjectGitSource,
|
|
)
|
|
from app.infrastructure.db.models import ProjectRow
|
|
from app.infrastructure.repositories.projects import (
|
|
ProjectExternalGitRepository,
|
|
ProjectGitCredentialRepository,
|
|
ProjectGitRepoRepository,
|
|
ProjectRepository,
|
|
)
|
|
|
|
logger = logging.getLogger("agy.container.delivery")
|
|
|
|
_fake_workspace = FakeRepoWorkspace()
|
|
_null_hooks = NullDeliveryHooks()
|
|
_fake_deploy_tester = FakeDeployTargetTester()
|
|
|
|
|
|
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
|
|
"""The platform's git adapter, or the in-memory fake when ``GIT_WORKSPACE=fake``."""
|
|
if settings.git_workspace.strip().lower() != "local":
|
|
return _fake_workspace
|
|
|
|
projects = ProjectRepository(session)
|
|
repos = ProjectGitRepoRepository(session)
|
|
credentials = ProjectGitCredentialRepository(session)
|
|
external = ProjectExternalGitRepository(session)
|
|
|
|
async def resolve(project_id: str) -> ProjectGitSource | None:
|
|
project = await projects.get(project_id)
|
|
if project is None:
|
|
return None
|
|
|
|
row = await session.get(ProjectRow, project_id)
|
|
if row is None:
|
|
return None
|
|
git_source = (row.git_source or "platform").lower()
|
|
|
|
if git_source == "external":
|
|
url = (row.external_repo_url or "").strip()
|
|
if not url:
|
|
return None
|
|
record = await external.get_for_project(project_id)
|
|
return ProjectGitSource(
|
|
project_id=project_id,
|
|
organization_id=project.organization_id,
|
|
clone_url=url,
|
|
default_branch=project.default_branch,
|
|
auth_type=(record.auth_type if record else "none") or "none",
|
|
username=record.username if record else None,
|
|
secret=_decrypt_or_none(
|
|
record.secret_encrypted if record else None, project_id
|
|
),
|
|
known_hosts=record.known_hosts if record else None,
|
|
is_platform=False,
|
|
)
|
|
|
|
repo = await repos.get_for_project(project_id)
|
|
if repo is None or not repo.clone_url:
|
|
return None
|
|
credential = await credentials.get_for_project(project_id)
|
|
secret = _decrypt_or_none(
|
|
credential.encrypted_password if credential else None, project_id
|
|
)
|
|
return ProjectGitSource(
|
|
project_id=project_id,
|
|
organization_id=project.organization_id,
|
|
clone_url=repo.clone_url,
|
|
default_branch=repo.default_branch or project.default_branch,
|
|
auth_type="token" if secret else "none",
|
|
username=credential.username if credential else None,
|
|
secret=secret,
|
|
is_platform=True,
|
|
)
|
|
|
|
return LocalGitWorkspaceAdapter(resolver=resolve)
|
|
|
|
|
|
def _decrypt_or_none(ciphertext: str | None, project_id: str) -> str | None:
|
|
"""Decrypt a stored secret, tolerating a rotated ``ENCRYPTION_KEY``.
|
|
|
|
A rotated key must surface as "no credential" (git then fails with an auth
|
|
error the user can act on) rather than as an exception that breaks an
|
|
unrelated request. The failure is logged, never raised with the ciphertext.
|
|
"""
|
|
if not ciphertext:
|
|
return None
|
|
try:
|
|
return decrypt(ciphertext)
|
|
except Exception: # noqa: BLE001 - see docstring
|
|
logger.warning("git credential for project %s could not be decrypted", project_id)
|
|
return None
|
|
|
|
|
|
def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks:
|
|
return _null_hooks
|
|
|
|
|
|
def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester:
|
|
return _fake_deploy_tester
|