Files
stackpilot/backend/services/git_service.py
T
menzeljandClaude Opus 5 9247ff9621
CI / check (push) Successful in 13m10s
CI / build-and-push (push) Successful in 3m50s
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>
2026-09-18 01:06:29 +02:00

448 lines
17 KiB
Python

"""Deploying a stack from a Git repository.
The repository is the source of truth: a sync makes the stack's files match what
the repo says, and optionally runs ``compose up -d`` when that changed anything.
Two things about *how* are worth stating up front, because both are places this
could quietly destroy data.
**The clone does not live in the stack folder.** It is cached under
``${DATA_DIR}/git/<stack_id>`` and the relevant subtree is copied across. A
stack folder holds more than the repo's files — compose creates bind-mount
directories like ``./config`` right there, full of live application data — so a
``git reset --hard`` or ``git clean`` in that folder would be catastrophic. In
the cache directory both are safe, and the copy step is where the care goes.
**Only files the repo has provided are ever deleted.** Each sync records the
paths it wrote (``GitSource.managed_files``); the next sync removes the ones the
repo no longer has, and nothing else. A file that was never in the repository
cannot be touched, no matter what happened to it.
Credentials never reach a command line. A token is passed to git through
``GIT_ASKPASS`` and an environment variable, an SSH key through a 0600 file
outside the working tree — so neither shows up in ``ps``, in the repo's own
config, or in an error message this module passes on.
"""
from __future__ import annotations
import asyncio
import filecmp
import hmac
import json
import logging
import os
import re
import secrets
import shutil
import stat
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Session
from config import settings
from models.git_source import GitSource, SyncResult
from services import compose_service, crypto_service, stack_lock_service
logger = logging.getLogger("stackpilot.git")
GIT_TIMEOUT = 300.0
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
class GitError(Exception):
"""A repository that cannot be reached, or a sync that cannot be completed."""
# --------------------------------------------------------------------------- #
# Paths
# --------------------------------------------------------------------------- #
def cache_root() -> str:
return os.path.join(settings.DATA_DIR, "git")
def repo_dir(stack_id: str) -> str:
if not _SAFE_ID_RE.match(stack_id or "") or stack_id in (".", ".."):
raise GitError(f"Invalid stack id '{stack_id}'")
return os.path.join(cache_root(), stack_id)
def _key_path(stack_id: str) -> str:
# Beside the clone, never inside it — a working tree gets reset and cleaned.
return os.path.join(cache_root(), f"{stack_id}.key")
def _askpass_path(stack_id: str) -> str:
return os.path.join(cache_root(), f"{stack_id}.askpass")
def new_webhook_secret() -> str:
return secrets.token_urlsafe(24)
# --------------------------------------------------------------------------- #
# Running git
# --------------------------------------------------------------------------- #
def _redact(text: str, *secrets_: Optional[str]) -> str:
"""Strip anything secret out of git's output before it is shown or stored."""
for value in secrets_:
if value:
text = text.replace(value, "••••••")
# A URL that carries credentials, in case one ever reaches git's output.
return re.sub(r"(https?://)[^/\s:@]+:[^/\s@]+@", r"\1••••••@", text)
async def _git(args: list[str], env: dict, secret: Optional[str] = None) -> str:
"""Run git, returning stdout. Raises GitError with a redacted message."""
proc = await asyncio.create_subprocess_exec(
"git",
*args,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=GIT_TIMEOUT)
except asyncio.TimeoutError as exc:
proc.kill()
raise GitError("git timed out") from exc
out = out_b.decode("utf-8", "replace")
if proc.returncode != 0:
err = _redact(err_b.decode("utf-8", "replace").strip(), secret)
raise GitError(err or f"git {args[0]} failed (exit {proc.returncode})")
return out
def _auth_env(source: GitSource) -> tuple[dict, Optional[str]]:
"""Environment for git, plus the plaintext secret so output can be redacted.
Credentials go in the environment, never in argv: ``ps`` is readable by
every process on the host, and StackPilot runs in a container people share
with their whole stack.
"""
env = {
**os.environ,
# No interactive prompting: a private repo without credentials must fail
# fast rather than hang forever waiting on a terminal that is not there.
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_NOSYSTEM": "1",
"HOME": cache_root(),
}
if source.auth_type == "none" or not source.secret:
return env, None
plaintext = crypto_service.decrypt(source.secret)
os.makedirs(cache_root(), mode=0o700, exist_ok=True)
if source.auth_type == "ssh":
key_file = _key_path(source.stack_id)
fd = os.open(key_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(plaintext.rstrip("\n") + "\n")
env["GIT_SSH_COMMAND"] = (
f"ssh -i {key_file} -o IdentitiesOnly=yes "
# accept-new pins the host key on first contact and refuses it if it
# ever changes, which is the strongest option that does not require
# the operator to paste a fingerprint by hand.
"-o StrictHostKeyChecking=accept-new "
f"-o UserKnownHostsFile={os.path.join(cache_root(), 'known_hosts')}"
)
return env, plaintext
# token: HTTPS basic auth, handed over through an askpass helper.
askpass = _askpass_path(source.stack_id)
fd = os.open(askpass, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o700)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write('#!/bin/sh\ncase "$1" in Username*) echo "$GIT_USER";; *) echo "$GIT_TOKEN";; esac\n')
env["GIT_ASKPASS"] = askpass
env["GIT_USER"] = source.username or "git"
env["GIT_TOKEN"] = plaintext
return env, plaintext
def _cleanup_auth(source: GitSource) -> None:
for path in (_key_path(source.stack_id), _askpass_path(source.stack_id)):
try:
os.remove(path)
except OSError:
pass
# --------------------------------------------------------------------------- #
# Fetching
# --------------------------------------------------------------------------- #
async def _fetch(source: GitSource, env: dict, secret: Optional[str]) -> str:
"""Bring the cached clone to the tip of the configured branch. Returns the commit."""
directory = repo_dir(source.stack_id)
branch = source.branch or "main"
os.makedirs(cache_root(), mode=0o700, exist_ok=True)
if os.path.isdir(os.path.join(directory, ".git")):
try:
remote = (await _git(["-C", directory, "remote", "get-url", "origin"], env, secret)).strip()
except GitError:
remote = ""
if remote != source.url:
# Repointed at a different repository: start clean rather than try
# to reconcile two unrelated histories.
shutil.rmtree(directory, ignore_errors=True)
if not os.path.isdir(os.path.join(directory, ".git")):
await _git(
["clone", "--depth", "1", "--branch", branch, source.url, directory], env, secret
)
else:
await _git(["-C", directory, "fetch", "--depth", "1", "origin", branch], env, secret)
await _git(["-C", directory, "checkout", "-B", branch, "FETCH_HEAD"], env, secret)
await _git(["-C", directory, "reset", "--hard", "FETCH_HEAD"], env, secret)
# Safe here and only here: this directory holds nothing but the clone.
await _git(["-C", directory, "clean", "-fdx"], env, secret)
return (await _git(["-C", directory, "rev-parse", "HEAD"], env, secret)).strip()
# --------------------------------------------------------------------------- #
# Copying the repo's files into the stack
# --------------------------------------------------------------------------- #
def _inside(path: str, parent: str) -> bool:
return os.path.realpath(path).startswith(os.path.realpath(parent).rstrip("/") + "/")
def _source_tree(source: GitSource) -> str:
directory = repo_dir(source.stack_id)
subdir = (source.subdir or "").strip().strip("/")
if not subdir:
return directory
tree = os.path.join(directory, subdir)
# The subdirectory comes from user input and is about to be walked.
if not _inside(tree, directory):
raise GitError(f"Subdirectory '{source.subdir}' leaves the repository")
if not os.path.isdir(tree):
raise GitError(f"'{source.subdir}' does not exist in the repository")
return tree
def _materialise(source: GitSource, previous: list[str]) -> tuple[list[str], list[str], list[str]]:
"""Copy the repo subtree into the stack folder.
Returns (current, written, removed): everything the repo provides, the
subset that actually changed on disk, and the files dropped because the repo
no longer has them.
"""
tree = _source_tree(source)
stack_dir = compose_service.stack_dir(source.stack_id)
os.makedirs(stack_dir, exist_ok=True)
current: list[str] = []
written: list[str] = []
for root, dirs, files in os.walk(tree):
dirs[:] = [d for d in dirs if d != ".git"]
for name in files:
src = os.path.join(root, name)
rel = os.path.relpath(src, tree)
dest = os.path.join(stack_dir, rel)
if not _inside(dest, stack_dir):
continue # a symlinked path trying to escape the stack folder
current.append(rel)
# shallow=False: compare contents, not just size and mtime, or a
# revert to a same-sized earlier version would look like no change.
if os.path.isfile(dest) and filecmp.cmp(src, dest, shallow=False):
continue
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(src, dest)
written.append(rel)
removed: list[str] = []
for rel in previous:
if rel in current:
continue
dest = os.path.join(stack_dir, rel)
if not _inside(dest, stack_dir) or not os.path.isfile(dest):
continue
try:
os.remove(dest)
removed.append(rel)
except OSError as exc:
logger.warning("Could not remove %s: %s", dest, exc)
_prune_empty_dirs(stack_dir, removed)
return sorted(current), sorted(written), sorted(removed)
def _prune_empty_dirs(stack_dir: str, removed: list[str]) -> None:
"""Drop directories left empty by removed files, never the stack folder."""
for rel in removed:
directory = os.path.dirname(os.path.join(stack_dir, rel))
while _inside(directory, stack_dir):
try:
os.rmdir(directory) # fails unless empty, which is what we want
except OSError:
break
directory = os.path.dirname(directory)
# --------------------------------------------------------------------------- #
# Syncing
# --------------------------------------------------------------------------- #
async def sync(session: Session, source: GitSource, actor: str = "system") -> SyncResult:
"""Fetch, copy into the stack, and deploy when something changed."""
env, secret = _auth_env(source)
try:
commit = await _fetch(source, env, secret)
previous = _managed(source)
current, written, removed = _materialise(source, previous)
except GitError as exc:
source.last_error = _redact(str(exc), secret)[:1000]
source.updated_at = datetime.now(timezone.utc)
session.add(source)
session.commit()
raise
finally:
_cleanup_auth(source)
changed = bool(written or removed)
source.managed_files = json.dumps(current)
source.last_commit = commit
source.last_synced_at = datetime.now(timezone.utc)
source.updated_at = source.last_synced_at
source.last_error = None
session.add(source)
session.commit()
result = SyncResult(changed=changed, commit=commit, written=written, removed=removed)
if changed and source.auto_deploy:
result.deployed, result.detail = await _deploy(session, source.stack_id, actor)
return result
async def _deploy(session: Session, stack_id: str, actor: str) -> tuple[bool, Optional[str]]:
"""`compose up -d`, under the same lock every other lifecycle action takes."""
from services import audit_service
try:
with stack_lock_service.hold(session, stack_id, "git-deploy", actor):
outcome = await compose_service.up(stack_id)
except stack_lock_service.StackBusy as exc:
# Somebody is already deploying. The files are updated; say so rather
# than queue a second compose run at the same project.
return False, f"stack is busy ({exc.action}); files synced but not deployed"
except Exception as exc: # noqa: BLE001 - a failed deploy must not lose the sync
return False, str(exc)[:500]
ok = outcome.get("returncode") in (0, None)
audit_service.record(
session, user=actor, action="stack.git-deploy", target=stack_id,
detail=f"rc={outcome.get('returncode')}",
)
return ok, (outcome.get("stderr") or "").strip()[-1000:] or None
def _managed(source: GitSource) -> list[str]:
try:
value = json.loads(source.managed_files or "[]")
except json.JSONDecodeError:
return []
return [str(v) for v in value] if isinstance(value, list) else []
def forget(stack_id: str) -> None:
"""Drop the cached clone and any credential files for a stack."""
try:
shutil.rmtree(repo_dir(stack_id), ignore_errors=True)
except GitError:
return
for path in (_key_path(stack_id), _askpass_path(stack_id)):
try:
os.remove(path)
except OSError:
pass
# --------------------------------------------------------------------------- #
# Webhooks
# --------------------------------------------------------------------------- #
def verify_webhook(source: GitSource, body: bytes, headers) -> bool:
"""Is this webhook really from the forge that holds our secret?
Supports the two schemes between them covered by GitHub, Gitea, Forgejo and
GitLab. Both comparisons are constant-time.
"""
expected = source.webhook_secret or ""
if not expected:
return False
signature = headers.get("X-Hub-Signature-256") or ""
if signature.startswith("sha256="):
digest = hmac.new(expected.encode(), body, "sha256").hexdigest()
return hmac.compare_digest(signature[len("sha256=") :], digest)
gitlab = headers.get("X-Gitlab-Token") or ""
if gitlab:
return hmac.compare_digest(gitlab, expected)
return False
# --------------------------------------------------------------------------- #
# Polling
# --------------------------------------------------------------------------- #
def due(source: GitSource, now: Optional[datetime] = None) -> bool:
"""Is this source's polling interval up?"""
if not source.poll_interval_minutes or source.poll_interval_minutes < 1:
return False
if source.last_synced_at is None:
return True
last = source.last_synced_at
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
elapsed = (now or datetime.now(timezone.utc)) - last
return elapsed.total_seconds() >= source.poll_interval_minutes * 60
async def poll_loop(interval: float = 60.0) -> None:
"""Sync every repository whose interval is up. Never dies on one failure."""
from sqlmodel import select
from database import engine
while True:
await asyncio.sleep(interval)
try:
with Session(engine) as session:
sources = session.exec(select(GitSource)).all()
for source in sources:
if not due(source):
continue
try:
result = await sync(session, source, actor="poll")
if result.changed:
logger.info(
"Git sync updated %s to %s", source.stack_id,
(result.commit or "")[:8],
)
except Exception as exc: # noqa: BLE001 - one repo, not all
logger.warning("Git sync failed for %s: %s", source.stack_id, exc)
except Exception as exc: # noqa: BLE001 - the loop outlives everything
logger.warning("Git polling pass failed: %s", exc)
def ensure_cache_root() -> None:
"""The cache doubles as git's HOME, so it must exist and stay private."""
try:
os.makedirs(cache_root(), mode=0o700, exist_ok=True)
os.chmod(cache_root(), stat.S_IRWXU)
except OSError as exc:
logger.warning("Could not create the Git cache directory: %s", exc)