Deploy stacks from a Git repository (0.58.0)
StackPilot's stacks were already plain folders on disk, which makes GitOps less
of an architectural change than it would be elsewhere: a sync is "make these
files match that repo, then compose up". Almost all of the design effort went
into the word "these", because getting it wrong destroys data.
A stack folder is not just the compose file. Compose creates bind-mount
directories in it — ./config, ./data — and those hold the live state of whatever
is running. So the obvious implementation, clone into the stack folder and
git reset --hard, is a data-loss bug waiting for its first `git clean`. Instead
the clone lives in a cache under ${DATA_DIR}/git/<stack> where reset and clean
are safe, and the configured subtree is copied across. No .git ends up in the
stack folder, so backups and the file browser are unaffected too.
Deletion is the other half. Making a folder "match" a repo naively means
removing what the repo does not have, which is exactly the application data
above. So each sync records the paths it wrote, and the next sync may delete
only those — a file the repository never provided cannot be touched by any code
path here. Tested directly: a database file and a hand-written .env survive a
sync that replaces the compose file and removes a file the repo dropped.
What the repo does provide is overwritten, hand edits included. That is the
point of GitOps rather than a wart, but it is a surprise if you attach a repo to
a stack you have been editing, so the connect form says it before the first sync
and the first sync is never automatic.
The webhook is the only route in StackPilot with no bearer token, because a Git
forge has none to present. It authenticates with an HMAC over the body —
X-Hub-Signature-256 for GitHub/Gitea/Forgejo, X-Gitlab-Token for GitLab, both
compared in constant time — and answers 404, not 403, to anything unsigned. A
403 would confirm that a given stack exists and is connected to a repository,
which an unauthenticated caller has not earned. The authorization matrix test
caught this route being public and made me write that reasoning down in it,
which is exactly what that test is for.
Credentials never reach a command line: ps is readable by every process on the
host, and this runs in a container next to everything else. The HTTPS token goes
to git through GIT_ASKPASS and the environment, the SSH key through a 0600 file
kept outside the working tree, and everything git prints is scrubbed of both —
plus any credential-carrying URL — before it is stored in last_error or shown.
Auto-deploy takes the same per-stack lock as every other lifecycle action, so a
webhook firing mid-deploy reports "files synced, stack busy" instead of racing a
second compose run at the same project.
The image needed git and openssh-client, which is the only reason this release
touches the Dockerfile.
26 tests against real repositories created with the real git binary, none of
them touching the network — mocking git would mostly test the mock. Verified end
to end as well: connect, sync, a push that changes one file and deletes another,
a wrongly signed webhook, a correctly signed one, and the live data still there
afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user