Files
stackpilot/backend/tests/test_git_source.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

414 lines
14 KiB
Python

"""Deploying a stack from Git.
Real repositories, created locally with the real git binary — mocking git would
mostly test the mock. Nothing here reaches the network.
The test that matters most is the one about deletion. A stack folder holds live
application data next to the compose file: compose creates bind-mount
directories like ``./config`` right there. A sync that "makes the folder match
the repo" by clearing what the repo does not have would destroy exactly that, so
the rule is that only files the repository has itself provided may ever be
removed.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import pytest
from sqlmodel import Session, delete
@pytest.fixture
def svc(db):
from services import git_service
return git_service
@pytest.fixture(autouse=True)
def clean_sources(db):
from database import engine
from models.git_source import GitSource
def wipe():
with Session(engine) as session:
session.exec(delete(GitSource))
session.commit()
wipe()
yield
wipe()
def _run(*args, cwd):
subprocess.run(
args,
cwd=cwd,
check=True,
capture_output=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
},
)
class _Origin:
"""A real repository on disk. ``str()`` is its path, so it can be a clone URL."""
def __init__(self, path):
self.path = path
def __str__(self) -> str:
return str(self.path)
def commit(self, files: dict, message: str = "change") -> None:
for name, content in files.items():
path = self.path / name
if content is None:
path.unlink(missing_ok=True)
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
_run("git", "add", "-A", cwd=self.path)
_run("git", "commit", "-q", "-m", message, cwd=self.path)
@pytest.fixture
def origin(tmp_path):
repo = tmp_path / "origin"
repo.mkdir()
_run("git", "init", "-q", "-b", "main", cwd=repo)
made = _Origin(repo)
made.commit({"compose.yaml": "services:\n app:\n image: nginx:alpine\n"}, "init")
return made
@pytest.fixture
def source(db, origin, tmp_path, monkeypatch):
"""A stack wired to that repository, with both roots inside tmp_path."""
from config import settings
from database import engine
from models.git_source import GitSource
from models.stack import Stack
from services import git_service
monkeypatch.setattr(settings, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(settings, "STACKS_DIR", str(tmp_path / "stacks"))
os.makedirs(settings.STACKS_DIR, exist_ok=True)
git_service.ensure_cache_root()
with Session(engine) as session:
if (old := session.get(Stack, "gitstack")) is not None:
session.delete(old)
session.commit()
session.add(Stack(id="gitstack", name="Git Stack"))
row = GitSource(
stack_id="gitstack",
url=str(origin),
branch="main",
webhook_secret="s3cret",
)
session.add(row)
session.commit()
session.refresh(row)
row_id = row.id
with Session(engine) as session:
yield session, session.get(GitSource, row_id)
def _sync(session, row, svc):
return asyncio.run(svc.sync(session, row, actor="test"))
def _stack_dir() -> str:
from services import compose_service
return compose_service.stack_dir("gitstack")
# --------------------------------------------------------------------------- #
# Syncing
# --------------------------------------------------------------------------- #
def test_the_first_sync_brings_the_repo_into_the_stack(svc, source):
session, row = source
result = _sync(session, row, svc)
assert result.changed is True
assert result.written == ["compose.yaml"]
assert "nginx:alpine" in open(os.path.join(_stack_dir(), "compose.yaml")).read()
assert row.last_commit and row.last_error is None
def test_syncing_again_with_no_new_commit_changes_nothing(svc, source):
session, row = source
_sync(session, row, svc)
again = _sync(session, row, svc)
assert again.changed is False
assert again.written == [] and again.removed == []
def test_a_new_commit_is_picked_up(svc, source, origin):
session, row = source
_sync(session, row, svc)
origin.commit({"compose.yaml": "services:\n app:\n image: nginx:1.27\n"})
result = _sync(session, row, svc)
assert result.changed is True
assert "nginx:1.27" in open(os.path.join(_stack_dir(), "compose.yaml")).read()
def test_a_hand_edited_file_is_put_back(svc, source):
"""The repository is the source of truth; that is the whole point."""
session, row = source
_sync(session, row, svc)
path = os.path.join(_stack_dir(), "compose.yaml")
with open(path, "w") as fh:
fh.write("services: {}\n")
result = _sync(session, row, svc)
assert result.changed is True
assert "nginx:alpine" in open(path).read()
def test_only_the_configured_subdirectory_is_deployed(svc, source, origin):
session, row = source
origin.commit({"stacks/web/compose.yaml": "services:\n web:\n image: caddy\n"})
row.subdir = "stacks/web"
result = _sync(session, row, svc)
assert result.written == ["compose.yaml"]
assert "caddy" in open(os.path.join(_stack_dir(), "compose.yaml")).read()
def test_a_subdirectory_that_escapes_the_repo_is_refused(svc, source):
session, row = source
row.subdir = "../../../etc"
with pytest.raises(svc.GitError):
_sync(session, row, svc)
def test_a_missing_subdirectory_is_reported(svc, source):
session, row = source
row.subdir = "nope"
with pytest.raises(svc.GitError):
_sync(session, row, svc)
assert row.last_error and "nope" in row.last_error
def test_an_unreachable_repository_is_recorded_not_raised_away(svc, source, tmp_path):
session, row = source
row.url = str(tmp_path / "does-not-exist")
with pytest.raises(svc.GitError):
_sync(session, row, svc)
# Stored, so the UI can show why the last attempt failed.
assert row.last_error
# --------------------------------------------------------------------------- #
# What may be deleted — the dangerous part
# --------------------------------------------------------------------------- #
def test_a_file_dropped_from_the_repo_is_removed_from_the_stack(svc, source, origin):
session, row = source
origin.commit({"extra.env": "A=1\n"})
_sync(session, row, svc)
assert os.path.isfile(os.path.join(_stack_dir(), "extra.env"))
origin.commit({"extra.env": None}, "drop it")
result = _sync(session, row, svc)
assert result.removed == ["extra.env"]
assert not os.path.exists(os.path.join(_stack_dir(), "extra.env"))
def test_files_the_repo_never_had_are_never_touched(svc, source, origin):
"""Compose puts live application data in the stack folder. It must survive."""
session, row = source
_sync(session, row, svc)
data_dir = os.path.join(_stack_dir(), "config")
os.makedirs(data_dir, exist_ok=True)
with open(os.path.join(data_dir, "app.db"), "w") as fh:
fh.write("precious")
with open(os.path.join(_stack_dir(), ".env"), "w") as fh:
fh.write("SECRET=hunter2\n")
origin.commit({"compose.yaml": "services:\n app:\n image: nginx:1.27\n"})
_sync(session, row, svc)
assert open(os.path.join(data_dir, "app.db")).read() == "precious"
assert open(os.path.join(_stack_dir(), ".env")).read() == "SECRET=hunter2\n"
def test_a_file_the_repo_stops_providing_is_only_removed_if_it_provided_it(svc, source, origin):
"""A file the stack folder already had before Git ever touched it."""
session, row = source
os.makedirs(_stack_dir(), exist_ok=True)
with open(os.path.join(_stack_dir(), "notes.txt"), "w") as fh:
fh.write("mine")
_sync(session, row, svc)
# The repo never provided notes.txt, so the first sync left it alone.
assert os.path.isfile(os.path.join(_stack_dir(), "notes.txt"))
assert "notes.txt" not in json.loads(row.managed_files)
def test_managed_files_are_recorded_for_the_next_sync(svc, source, origin):
session, row = source
origin.commit({"a.yaml": "a", "sub/b.yaml": "b"})
_sync(session, row, svc)
assert sorted(json.loads(row.managed_files)) == [
"a.yaml",
"compose.yaml",
os.path.join("sub", "b.yaml"),
]
def test_a_directory_left_empty_by_a_removal_is_pruned(svc, source, origin):
session, row = source
origin.commit({"sub/b.yaml": "b"})
_sync(session, row, svc)
assert os.path.isdir(os.path.join(_stack_dir(), "sub"))
origin.commit({"sub/b.yaml": None}, "drop")
_sync(session, row, svc)
assert not os.path.exists(os.path.join(_stack_dir(), "sub"))
# But never the stack folder itself.
assert os.path.isdir(_stack_dir())
# --------------------------------------------------------------------------- #
# Webhook authorization
# --------------------------------------------------------------------------- #
def _sig(secret: str, body: bytes) -> str:
import hmac
return "sha256=" + hmac.new(secret.encode(), body, "sha256").hexdigest()
def test_a_correctly_signed_webhook_is_accepted(svc, source):
_session, row = source
body = b'{"ref":"refs/heads/main"}'
assert svc.verify_webhook(row, body, {"X-Hub-Signature-256": _sig("s3cret", body)})
def test_a_wrong_signature_is_rejected(svc, source):
_session, row = source
body = b'{"ref":"refs/heads/main"}'
assert not svc.verify_webhook(row, body, {"X-Hub-Signature-256": _sig("wrong", body)})
# A signature over different content must not carry over.
assert not svc.verify_webhook(row, b"tampered", {"X-Hub-Signature-256": _sig("s3cret", body)})
def test_an_unsigned_webhook_is_rejected(svc, source):
_session, row = source
assert not svc.verify_webhook(row, b"{}", {})
def test_the_gitlab_token_header_works_too(svc, source):
_session, row = source
assert svc.verify_webhook(row, b"{}", {"X-Gitlab-Token": "s3cret"})
assert not svc.verify_webhook(row, b"{}", {"X-Gitlab-Token": "nope"})
def test_a_source_with_no_secret_accepts_nothing(svc, source):
_session, row = source
row.webhook_secret = ""
assert not svc.verify_webhook(row, b"{}", {"X-Gitlab-Token": ""})
def test_the_webhook_endpoint_hides_whether_a_stack_is_connected(client, source):
"""Unsigned calls get 404, so the endpoint cannot be used to enumerate."""
connected = client.post("/api/git/webhook/gitstack", content=b"{}")
unknown = client.post("/api/git/webhook/no-such-stack", content=b"{}")
assert connected.status_code == 404
assert unknown.status_code == 404
assert connected.json() == unknown.json()
# --------------------------------------------------------------------------- #
# Polling
# --------------------------------------------------------------------------- #
def test_polling_is_off_unless_an_interval_is_set(svc, source):
_session, row = source
row.poll_interval_minutes = None
assert svc.due(row) is False
row.poll_interval_minutes = 0
assert svc.due(row) is False
def test_a_source_that_has_never_synced_is_due(svc, source):
_session, row = source
row.poll_interval_minutes = 15
row.last_synced_at = None
assert svc.due(row) is True
def test_due_respects_the_interval(svc, source):
from datetime import datetime, timedelta, timezone
_session, row = source
row.poll_interval_minutes = 15
now = datetime.now(timezone.utc)
row.last_synced_at = now - timedelta(minutes=5)
assert svc.due(row, now) is False
row.last_synced_at = now - timedelta(minutes=16)
assert svc.due(row, now) is True
# --------------------------------------------------------------------------- #
# Secrets never leak
# --------------------------------------------------------------------------- #
def test_the_api_never_returns_the_stored_secret(as_admin, source):
from services import crypto_service
session, row = source
row.auth_type = "token"
row.username = "bob"
row.secret = crypto_service.encrypt("ghp_supersecret")
session.add(row)
session.commit()
body = as_admin.get("/api/stacks/gitstack/git").text
assert "ghp_supersecret" not in body
assert '"has_secret":true' in body.replace(" ", "")
def test_a_token_is_scrubbed_from_error_output(svc):
message = "fatal: could not read from https://bob:ghp_supersecret@example.com/x.git"
assert "ghp_supersecret" not in svc._redact(message, "ghp_supersecret")
# Even without being told the value, a credential-carrying URL is masked.
assert "ghp_supersecret" not in svc._redact(message)
def test_connecting_and_disconnecting_through_the_api(as_admin, source, origin):
connected = as_admin.put(
"/api/stacks/gitstack/git",
json={"url": str(origin), "branch": "main", "auto_deploy": False},
)
assert connected.status_code == 200, connected.text
assert connected.json()["webhook_url"] == "/api/git/webhook/gitstack"
assert as_admin.delete("/api/stacks/gitstack/git").status_code == 200
assert as_admin.get("/api/stacks/gitstack/git").status_code == 404
def test_the_read_only_role_cannot_touch_git_settings(as_user, source):
assert as_user.get("/api/stacks/gitstack/git").status_code == 403
assert as_user.post("/api/stacks/gitstack/git/sync").status_code == 403