"""Deploying stacks from Git. Everything here is admin-only except the webhook, which cannot be: a Git forge has no StackPilot credentials to present. It authenticates with an HMAC over the request body instead, against a secret generated per stack — see ``git_service.verify_webhook``. """ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from auth import require_admin from database import get_session from models.git_source import ( AUTH_TYPES, GitSource, GitSourceRead, GitSourceWrite, SyncResult, ) from models.stack import Stack from models.user import User from services import audit_service, crypto_service, git_service router = APIRouter(prefix="/api/stacks/{stack_id}/git", tags=["git"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _stack_or_404(session: Session, stack_id: str) -> Stack: stack = session.get(Stack, stack_id) if not stack: raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found") return stack def _source(session: Session, stack_id: str) -> GitSource: row = session.exec(select(GitSource).where(GitSource.stack_id == stack_id)).first() if not row: raise HTTPException( status_code=404, detail=f"Stack '{stack_id}' is not connected to a repository" ) return row def _to_read(row: GitSource) -> GitSourceRead: return GitSourceRead( stack_id=row.stack_id, url=row.url, branch=row.branch, subdir=row.subdir, auth_type=row.auth_type, username=row.username, has_secret=bool(row.secret), auto_deploy=row.auto_deploy, poll_interval_minutes=row.poll_interval_minutes, # Relative on purpose: StackPilot does not know its own external URL, # and guessing one into a forge's webhook settings would be worse than # letting the UI prefix the address the admin is already looking at. webhook_url=f"/api/git/webhook/{row.stack_id}", last_commit=row.last_commit, last_synced_at=row.last_synced_at, last_error=row.last_error, managed_file_count=len(git_service._managed(row)), ) @router.get("", response_model=GitSourceRead) def get_source( stack_id: str, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> GitSourceRead: return _to_read(_source(session, stack_id)) @router.put("", response_model=GitSourceRead) def connect( stack_id: str, body: GitSourceWrite, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> GitSourceRead: """Connect a stack to a repository, or change how it is connected. Does not sync — the caller decides when, because the first sync overwrites the stack's compose file with whatever the repository says. """ _stack_or_404(session, stack_id) if body.auth_type not in AUTH_TYPES: raise HTTPException(status_code=400, detail=f"Unknown auth type '{body.auth_type}'") if not (body.url or "").strip(): raise HTTPException(status_code=400, detail="A repository URL is required") row = session.exec(select(GitSource).where(GitSource.stack_id == stack_id)).first() if row is None: row = GitSource(stack_id=stack_id, url="", webhook_secret=git_service.new_webhook_secret()) row.url = body.url.strip() row.branch = (body.branch or "main").strip() or "main" row.subdir = (body.subdir or "").strip().strip("/") row.auth_type = body.auth_type row.username = body.username if body.secret: row.secret = crypto_service.encrypt(body.secret) elif body.auth_type == "none": row.secret = None row.auto_deploy = body.auto_deploy row.poll_interval_minutes = body.poll_interval_minutes or None row.updated_at = datetime.now(timezone.utc) session.add(row) session.commit() session.refresh(row) audit_service.record( session, user=user.username, action="stack.git-connect", target=stack_id, detail=f"{row.url}#{row.branch}", ip=_ip(request), ) return _to_read(row) @router.delete("") def disconnect( stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: """Stop tracking the repository. The stack's files are left exactly as they are.""" row = _source(session, stack_id) session.delete(row) session.commit() git_service.forget(stack_id) audit_service.record( session, user=user.username, action="stack.git-disconnect", target=stack_id, ip=_ip(request), ) return {"ok": True} @router.post("/sync", response_model=SyncResult) async def sync_now( stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> SyncResult: row = _source(session, stack_id) try: result = await git_service.sync(session, row, actor=user.username) except git_service.GitError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc audit_service.record( session, user=user.username, action="stack.git-sync", target=stack_id, detail=f"{(result.commit or '')[:8]} {'changed' if result.changed else 'no change'}", ip=_ip(request), ) return result @router.get("/webhook-secret") def reveal_webhook_secret( stack_id: str, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> dict: """The secret to paste into the forge's webhook settings. Readable rather than shown-once: it lives in the forge's configuration too, so hiding it here would only mean re-pointing the webhook to see it again. """ return {"secret": _source(session, stack_id).webhook_secret} @router.post("/webhook-secret") def rotate_webhook_secret( stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: row = _source(session, stack_id) row.webhook_secret = git_service.new_webhook_secret() row.updated_at = datetime.now(timezone.utc) session.add(row) session.commit() audit_service.record( session, user=user.username, action="stack.git-rotate-secret", target=stack_id, ip=_ip(request), ) return {"secret": row.webhook_secret} # --------------------------------------------------------------------------- # # The webhook # --------------------------------------------------------------------------- # hook_router = APIRouter(prefix="/api/git", tags=["git"]) @hook_router.post("/webhook/{stack_id}") async def webhook( stack_id: str, request: Request, session: Session = Depends(get_session), ) -> dict: """Push webhook from a Git forge. Unauthenticated in the usual sense — a forge holds no StackPilot session — and authorized by an HMAC over the body instead. An unsigned or wrongly signed call is a 404, not a 403: without credentials to present, telling a caller that a given stack *is* connected to a repository is information it has not earned. """ row = session.exec(select(GitSource).where(GitSource.stack_id == stack_id)).first() body = await request.body() if not row or not git_service.verify_webhook(row, body, request.headers): raise HTTPException(status_code=404, detail="Not found") try: result = await git_service.sync(session, row, actor="webhook") except git_service.GitError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc audit_service.record( session, user="webhook", action="stack.git-sync", target=stack_id, detail=f"{(result.commit or '')[:8]} {'changed' if result.changed else 'no change'}", ip=_ip(request), ) return { "ok": True, "changed": result.changed, "commit": result.commit, "deployed": result.deployed, }