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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.57.0",
"version": "0.58.0",
"type": "module",
"scripts": {
"dev": "vite",
+58
View File
@@ -0,0 +1,58 @@
import api from "./client";
export type GitAuthType = "none" | "token" | "ssh";
export interface GitSource {
stack_id: string;
url: string;
branch: string;
subdir: string;
auth_type: GitAuthType;
username: string | null;
/** The token or key itself is never sent to the browser. */
has_secret: boolean;
auto_deploy: boolean;
poll_interval_minutes: number | null;
/** Relative — the UI prefixes the origin it is being viewed from. */
webhook_url: string;
last_commit: string | null;
last_synced_at: string | null;
last_error: string | null;
managed_file_count: number;
}
export interface GitSourceInput {
url: string;
branch: string;
subdir: string;
auth_type: GitAuthType;
username?: string;
/** Omit when editing to keep the stored one. */
secret?: string;
auto_deploy: boolean;
poll_interval_minutes?: number | null;
}
export interface SyncResult {
changed: boolean;
commit: string | null;
written: string[];
removed: string[];
deployed: boolean;
detail: string | null;
}
const base = (stackId: string) => `/api/stacks/${stackId}/git`;
export const gitApi = {
get: (stackId: string) => api.get<GitSource>(base(stackId)).then((r) => r.data),
connect: (stackId: string, body: GitSourceInput) =>
api.put<GitSource>(base(stackId), body).then((r) => r.data),
disconnect: (stackId: string) => api.delete(base(stackId)).then((r) => r.data),
sync: (stackId: string) =>
api.post<SyncResult>(`${base(stackId)}/sync`).then((r) => r.data),
webhookSecret: (stackId: string) =>
api.get<{ secret: string }>(`${base(stackId)}/webhook-secret`).then((r) => r.data),
rotateWebhookSecret: (stackId: string) =>
api.post<{ secret: string }>(`${base(stackId)}/webhook-secret`).then((r) => r.data),
};
+437
View File
@@ -0,0 +1,437 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
GitBranch,
RefreshCw,
Unlink,
Copy,
Check,
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { gitApi, type GitAuthType, type GitSource } from "@/api/git";
import { apiErrorMessage } from "@/api/client";
import { relativeTime } from "@/lib/utils";
/**
* Deploying a stack from a Git repository.
*
* The one thing this panel has to communicate honestly is that the repository
* wins: a sync overwrites the stack's compose file with whatever the repo says.
* That is the point of GitOps, and it is also a surprise if you attach a repo
* to a stack you have been editing by hand — so the connect button says so
* before the first sync, not after.
*/
export function GitPanel({ stackId, isAdmin }: { stackId: string; isAdmin: boolean }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["git-source", stackId],
queryFn: () => gitApi.get(stackId),
// 404 simply means "not connected", which is a normal state, not a failure.
retry: false,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["git-source", stackId] });
qc.invalidateQueries({ queryKey: ["stack", stackId] });
};
if (!isAdmin) {
return (
<Card>
<p className="text-sm text-slate-500">
Git deployment is managed by administrators.
</p>
</Card>
);
}
if (isLoading) return <Spinner />;
const connected = !error && data;
if (!connected || editing) {
return (
<ConnectForm
stackId={stackId}
existing={editing ? data : undefined}
onDone={() => {
setEditing(false);
invalidate();
}}
onCancel={editing ? () => setEditing(false) : undefined}
/>
);
}
return (
<ConnectedView
source={data}
onEdit={() => setEditing(true)}
onChange={invalidate}
/>
);
}
function ConnectedView({
source,
onEdit,
onChange,
}: {
source: GitSource;
onEdit: () => void;
onChange: () => void;
}) {
const [disconnecting, setDisconnecting] = useState(false);
const sync = useMutation({
mutationFn: () => gitApi.sync(source.stack_id),
onSuccess: (result) => {
if (!result.changed) {
toast.success("Already up to date");
} else {
const changes = [
result.written.length && `${result.written.length} file(s) updated`,
result.removed.length && `${result.removed.length} removed`,
result.deployed && "redeployed",
].filter(Boolean);
toast.success(`Synced ${result.commit?.slice(0, 8)}${changes.join(", ")}`);
if (result.changed && !result.deployed && result.detail) {
toast.error(result.detail);
}
}
onChange();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const disconnect = useMutation({
mutationFn: () => gitApi.disconnect(source.stack_id),
onSuccess: () => {
toast.success("Disconnected — the stack's files were left as they are");
setDisconnecting(false);
onChange();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<div className="space-y-4">
<Card className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<GitBranch className="h-4 w-4 text-slate-400" />
<span className="break-all font-medium">{source.url}</span>
<Badge>{source.branch}</Badge>
{source.subdir && <Badge>/{source.subdir}</Badge>}
</div>
<p className="mt-1 text-xs text-slate-500">
{source.last_synced_at ? (
<>
last synced {relativeTime(source.last_synced_at)}
{source.last_commit && ` · ${source.last_commit.slice(0, 8)}`}
{` · ${source.managed_file_count} file(s) from the repo`}
</>
) : (
"never synced"
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => sync.mutate()} loading={sync.isPending}>
<RefreshCw className="h-4 w-4" /> Sync now
</Button>
<Button variant="outline" onClick={onEdit}>
Edit
</Button>
<Button variant="ghost" onClick={() => setDisconnecting(true)}>
<Unlink className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<span>
{source.auto_deploy
? "Deploys automatically when a sync changes something"
: "Syncs files only — deploy by hand"}
</span>
<span>·</span>
<span>
{source.poll_interval_minutes
? `Polls every ${source.poll_interval_minutes} min`
: "No polling — webhook or manual"}
</span>
</div>
{source.last_error && (
<div className="flex gap-2 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="break-all">{source.last_error}</span>
</div>
)}
</Card>
<WebhookCard source={source} />
{disconnecting && (
<ConfirmDialog
title="Stop deploying from Git?"
message="The stack keeps the files it has now — nothing is deleted. It simply stops following the repository."
confirmLabel="Disconnect"
busy={disconnect.isPending}
onConfirm={() => disconnect.mutate()}
onCancel={() => setDisconnecting(false)}
/>
)}
</div>
);
}
function WebhookCard({ source }: { source: GitSource }) {
const [secret, setSecret] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const url = `${window.location.origin}${source.webhook_url}`;
const reveal = useMutation({
mutationFn: () => gitApi.webhookSecret(source.stack_id),
onSuccess: (r) => setSecret(r.secret),
onError: (e) => toast.error(apiErrorMessage(e)),
});
const rotate = useMutation({
mutationFn: () => gitApi.rotateWebhookSecret(source.stack_id),
onSuccess: (r) => {
setSecret(r.secret);
toast.success("New secret — update it in the repository's webhook settings");
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const copy = async (value: string, what: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(what);
setTimeout(() => setCopied(null), 2000);
} catch {
toast.error("Could not copy — select the text and copy it manually");
}
};
return (
<Card className="space-y-3">
<div>
<h3 className="sp-heading">Webhook</h3>
<p className="text-sm text-slate-500">
Point your repository's push webhook here to deploy on every push. Send
it as <code>application/json</code> with the secret below — GitHub,
Gitea and Forgejo sign the body with it, GitLab sends it as a token
header. Both are accepted.
</p>
</div>
<Field label="Payload URL" value={url} onCopy={() => copy(url, "url")} copied={copied === "url"} />
{secret ? (
<Field
label="Secret"
value={secret}
onCopy={() => copy(secret, "secret")}
copied={copied === "secret"}
/>
) : (
<Button variant="outline" onClick={() => reveal.mutate()} loading={reveal.isPending}>
Show secret
</Button>
)}
<div className="flex justify-end">
<Button variant="ghost" onClick={() => rotate.mutate()} loading={rotate.isPending}>
Rotate secret
</Button>
</div>
</Card>
);
}
function Field({
label,
value,
onCopy,
copied,
}: {
label: string;
value: string;
onCopy: () => void;
copied: boolean;
}) {
return (
<label className="block space-y-1">
<span className="sp-label">{label}</span>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs dark:bg-slate-800">
{value}
</code>
<Button variant="outline" onClick={onCopy}>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</label>
);
}
function ConnectForm({
stackId,
existing,
onDone,
onCancel,
}: {
stackId: string;
existing?: GitSource;
onDone: () => void;
onCancel?: () => void;
}) {
const [url, setUrl] = useState(existing?.url ?? "");
const [branch, setBranch] = useState(existing?.branch ?? "main");
const [subdir, setSubdir] = useState(existing?.subdir ?? "");
const [authType, setAuthType] = useState<GitAuthType>(existing?.auth_type ?? "none");
const [username, setUsername] = useState(existing?.username ?? "");
const [secret, setSecret] = useState("");
const [autoDeploy, setAutoDeploy] = useState(existing?.auto_deploy ?? true);
const [poll, setPoll] = useState(
existing?.poll_interval_minutes ? String(existing.poll_interval_minutes) : ""
);
const save = useMutation({
mutationFn: () =>
gitApi.connect(stackId, {
url: url.trim(),
branch: branch.trim() || "main",
subdir: subdir.trim(),
auth_type: authType,
username: username.trim() || undefined,
secret: secret || undefined,
auto_deploy: autoDeploy,
poll_interval_minutes: poll ? Number(poll) : null,
}),
onSuccess: () => {
toast.success(existing ? "Repository updated" : "Connected — run a sync to deploy it");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="space-y-3">
<div>
<h3 className="sp-heading">
{existing ? "Edit repository" : "Deploy this stack from Git"}
</h3>
<p className="text-sm text-slate-500">
The repository becomes the source of truth for this stack's compose
file: the first sync overwrites it, and later syncs undo anything
edited by hand here. Data your containers write into the stack folder
is never touched only files the repository itself provides.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 sm:col-span-2">
<span className="sp-label">Repository URL</span>
<Input
placeholder="https://github.com/me/homelab.git"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="sp-label">Branch</span>
<Input value={branch} onChange={(e) => setBranch(e.target.value)} />
</label>
<label className="space-y-1">
<span className="sp-label">Subdirectory (optional)</span>
<Input
placeholder="stacks/immich"
value={subdir}
onChange={(e) => setSubdir(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="sp-label">Authentication</span>
<select
value={authType}
onChange={(e) => setAuthType(e.target.value as GitAuthType)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="none">Public repository</option>
<option value="token">HTTPS + access token</option>
<option value="ssh">SSH key</option>
</select>
</label>
{authType === "token" && (
<label className="space-y-1">
<span className="sp-label">Username</span>
<Input value={username} onChange={(e) => setUsername(e.target.value)} />
</label>
)}
{authType === "token" && (
<label className="space-y-1 sm:col-span-2">
<span className="sp-label">Access token</span>
<Input
type="password"
autoComplete="new-password"
placeholder={existing?.has_secret ? "Leave blank to keep the stored one" : ""}
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</label>
)}
{authType === "ssh" && (
<label className="space-y-1 sm:col-span-2">
<span className="sp-label">Private key</span>
<textarea
rows={5}
placeholder={
existing?.has_secret
? "Leave blank to keep the stored key"
: "-----BEGIN OPENSSH PRIVATE KEY-----"
}
value={secret}
onChange={(e) => setSecret(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-xs dark:border-slate-600 dark:bg-slate-800"
/>
</label>
)}
<label className="space-y-1">
<span className="sp-label">Poll every (minutes)</span>
<Input
type="number"
min={1}
placeholder="Off — webhook or manual"
value={poll}
onChange={(e) => setPoll(e.target.value)}
/>
</label>
<label className="flex items-center gap-2 pt-6 text-sm">
<input
type="checkbox"
checked={autoDeploy}
onChange={(e) => setAutoDeploy(e.target.checked)}
className="h-4 w-4"
/>
Deploy automatically after a sync that changes something
</label>
</div>
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
<Button onClick={() => save.mutate()} loading={save.isPending} disabled={!url.trim()}>
{existing ? "Save" : "Connect repository"}
</Button>
</div>
</Card>
);
}
+3 -1
View File
@@ -20,6 +20,7 @@ import { ContainerCard } from "@/components/stacks/ContainerCard";
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { GitPanel } from "@/components/stacks/GitPanel";
import { StackIconEditor } from "@/components/stacks/IconPicker";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
@@ -29,7 +30,7 @@ import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets", "Git"] as const;
type Tab = (typeof TABS)[number];
export function StackDetail() {
@@ -128,6 +129,7 @@ export function StackDetail() {
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
{tab === "Git" && <GitPanel stackId={id} isAdmin={isAdmin} />}
{tab === "Secrets" && (
<SecretsPanel
stackId={id}