Files
stackpilot/backend/services/git_service.py
T
menzeljandClaude Opus 5.5 d6cebb3371
CI / check (push) Successful in 4m9s
CI / build-and-push (push) Failing after 16m10s
Fix what a review of the whole app found, end to end (0.67.1)
A pass over every feature against a real Docker daemon, then clicking
through the UI as admin and read-only user, on a desktop and a phone.

Security. /ws/deploy ran compose up for the read-only role; it is
admin-only now. A container's inspect view, a stack's compose file
(POSTGRES_PASSWORD: hunter2) and the viewer's .env tab no longer show
credentials to that role. Git branch and URL values that git would read
as options (-x, ext::) are refused. SFTP host keys are checked: pinned
on first use or entered by hand (key line or SHA256 fingerprint), a
different key is refused before the password is sent. A backup name
from the API can no longer reach outside the destination folder. The
file browser refuses to move or rename a browse root, as delete did.

Docker lifecycle. Closing the deploy console released the stack lock
while compose was still running and logged rc=None as success; deploys
and updates are now background runs that hold the lock to the end,
report the real exit code and can be re-attached to. Every closed log
view left a `compose logs -f` behind. A dozen open tabs exhausted the
default thread pool and hung the API; event and exec streams have their
own threads. Auto-update and the update endpoint ignored failed pulls.
A deleted git stack was recreated and started by the next webhook or
poll; deleting a stack now drops its git source, auto-update policy and
schedule entries. Deleting no longer walks into mounted shares, is
refused while a backup runs, and removes the containers even when the
compose file is broken. Stack folders compose has to normalise
(Nextcloud -> nextcloud) work everywhere and group correctly on the
volumes, networks and images pages.

Detection. Host port conflicts read the container's own network
namespace; /proc/1/net is read instead. Images referenced as docker.io/…
were never update-checked; local builds no longer report "needs
credentials". NVIDIA GPUs are read from the driver's procfs. The backup
dialog's "path mismatch" warning came from mountinfo's filesystem-relative
root and fired for any stacks folder on its own disk, ZFS dataset or
btrfs subvolume; the host path now comes from Docker's mounts.

Backups and destinations. The download ignored the folder/volume
selection (axios sent binds[]). Restoring under a new name kept the old
one. A report header past nginx's buffer turned a good backup into a
502. SFTP/S3 errors were bare 500s and "Test" did not try to write.
A destination used by a schedule could be deleted. Backup file names
are in the schedule's or the browser's time zone instead of UTC.

Editor and converter. `docker run` conversion produced invalid compose
for --network host, named volumes and -dit; --gpus and --mount were
dropped; a multi-line command pasted into the one-line field became
`image: ' '` (now a textarea, and flattened continuations are read).
Validation failed for env_file stacks. The editor overwrote unsaved
edits on every container event, lost them on a stray click (now asks),
threw when closing the diff, and its helper panel listed the starter
service "app" for every existing stack. Monaco is bundled instead of
loaded from jsdelivr, so the editor works offline (and monaco-editor
0.57 drops the dompurify advisory; axios and react-router updated).

UI. All times were two hours off: offset-less UTC from SQLite was read
as local time, and "next run" always said "just now". A token refresh
mid-session duplicated deploys, reset terminals and doubled logs. A
backend restart (update, self-update) signed everyone out on reload; the
page now waits for it. The editor was unusable on a phone, tables and
tabs overflowed, dialog text was black on the dark theme, and the file
browser opened on an error with the default roots. Read-only users get
"Admin only" pages instead of 403s; in-use networks offer no delete;
"Scan all" covers StackPilot's own stacks only and runs in the
background instead of timing out at 600s.

Deployment. nginx passes the port in Host (the OIDC redirect URI lost
:5009) and keeps an outer proxy's X-Forwarded-Proto. The frontend waits
for a healthy backend (healthcheck probes every 2s while starting). The
frontend image builds with npm ci. Secrets are 0644 in a 0700 folder so
non-root containers can read them; existing files are fixed at startup.
The test suite no longer starts real containers on a host with Docker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
2026-10-15 13:51:26 +02:00

480 lines
18 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 child_env, 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."""
#: What a branch name may look like here. git itself allows more, but not a
#: leading "-": as the refspec of `git fetch origin <branch>` that would be read
#: as an option (`--upload-pack=…` runs a command).
_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/+-]{0,199}$")
def validate_branch(branch: str) -> str:
branch = (branch or "").strip()
if not _BRANCH_RE.match(branch) or ".." in branch or branch.endswith((".lock", "/")):
raise GitError(f"Invalid branch name '{branch}'")
return branch
def validate_url(url: str) -> str:
url = (url or "").strip()
if not url or url.startswith("-") or any(c.isspace() for c in url):
raise GitError(f"Invalid repository URL '{url}'")
# git's ext:: and fd:: transports run commands or read file descriptors.
if url.lower().startswith(("ext::", "fd::")):
raise GitError("ext:: and fd:: repository URLs are not supported")
return url
# --------------------------------------------------------------------------- #
# 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 = {
**child_env(),
# 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 = validate_branch(source.branch or "main")
validate_url(source.url)
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")):
# "--" ends the options: a URL that starts with "-" is a URL.
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."""
from models.stack import Stack
if session.get(Stack, source.stack_id) is None:
# A source left behind by a deleted stack: syncing it would recreate
# the folder and, with auto deploy, start the stack again.
raise GitError(f"Stack '{source.stack_id}' no longer exists")
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)