Deploy stacks from a Git repository (0.58.0)
CI / check (push) Successful in 13m10s
CI / build-and-push (push) Successful in 3m50s

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:
menzelj
2026-09-18 01:06:29 +02:00
co-authored by Claude Opus 5
parent e650aa6833
commit 9247ff9621
14 changed files with 1763 additions and 5 deletions
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
#: How to reach a private repository. "token" is an HTTPS username + personal
#: access token; "ssh" is a private key.
AUTH_TYPES = ["none", "token", "ssh"]
class GitSource(SQLModel, table=True):
"""A Git repository that a stack's files are deployed from.
The repository is the source of truth: a sync overwrites the stack's files
with what the repo says, which is the whole point of GitOps and also the
thing to be careful about. Only files the repo has ever provided are touched
— see ``services/git_service.py`` — so the data directories compose creates
inside a stack folder are never at risk.
"""
id: Optional[int] = Field(default=None, primary_key=True)
stack_id: str = Field(index=True, unique=True)
url: str
branch: str = "main"
#: Subdirectory inside the repository holding the compose file. Empty means
#: the repository root, which is the common case for one-stack repos.
subdir: str = ""
auth_type: str = "none"
username: Optional[str] = None
#: Encrypted: the access token, or the SSH private key.
secret: Optional[str] = None
#: Run `compose up -d` after a sync that actually changed something.
auto_deploy: bool = True
#: Poll the repository this often. None means only manual syncs and webhooks.
poll_interval_minutes: Optional[int] = None
#: Shared secret for the webhook endpoint (HMAC, or GitLab's token header).
webhook_secret: str = ""
#: JSON list of the paths the last sync wrote, relative to the stack folder.
#: The only files a later sync is allowed to delete.
managed_files: str = "[]"
last_commit: Optional[str] = None
last_synced_at: Optional[datetime] = None
last_error: Optional[str] = None
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class GitSourceWrite(SQLModel):
url: str
branch: str = "main"
subdir: str = ""
auth_type: str = "none"
username: Optional[str] = None
#: Omitted on update keeps the stored one.
secret: Optional[str] = None
auto_deploy: bool = True
poll_interval_minutes: Optional[int] = None
class GitSourceRead(SQLModel):
stack_id: str
url: str
branch: str
subdir: str
auth_type: str
username: Optional[str]
has_secret: bool
auto_deploy: bool
poll_interval_minutes: Optional[int]
webhook_url: str
last_commit: Optional[str]
last_synced_at: Optional[datetime]
last_error: Optional[str]
managed_file_count: int
class SyncResult(SQLModel):
changed: bool
commit: Optional[str] = None
written: list[str] = []
removed: list[str] = []
deployed: bool = False
detail: Optional[str] = None