Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/public/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ VectorFlow encrypts sensitive data before storing it in PostgreSQL:
| Certificates | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` |
| OIDC client secret | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` |
| Sensitive node config fields | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` |
| Git access tokens | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` |
| User passwords | bcrypt (cost 12) | Built-in salt |
| TOTP secrets | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` |
| 2FA backup codes | SHA-256 hash | -- |
Expand Down
32 changes: 32 additions & 0 deletions docs/public/user-guide/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,35 @@ Secrets and certificates are stripped during promotion. After promoting a pipeli

- **Edit** -- Click the **Edit** button on the environment detail page to rename the environment or change its secret backend configuration.
- **Delete** -- Click the **Delete** button to permanently remove the environment. You must have the Admin role on the team to delete an environment.

## Git Integration

VectorFlow can automatically commit pipeline YAML files to a Git repository whenever a pipeline is deployed or deleted. This provides an audit trail, version history, and hook points for external CI/CD workflows.

### Setup

{% stepper %}
{% step %}
### Navigate to Environment Settings
Go to **Environments** and click on the environment you want to configure.
{% endstep %}
{% step %}
### Configure Git Integration
In the **Git Integration** card, enter:
- **Repository URL**: The HTTPS URL of your Git repo (e.g., `https://github.com/org/pipeline-configs.git`)
- **Branch**: The branch to commit to (default: `main`)
- **Access Token**: A personal access token with write access to the repo
{% endstep %}
{% step %}
### Test Connection
Click **Test Connection** to verify VectorFlow can reach the repository.
{% endstep %}
{% endstepper %}

### How It Works

When you deploy a pipeline, VectorFlow commits the generated YAML to `{environment-name}/{pipeline-name}.yaml` in the configured repository. When you delete a pipeline, the file is removed with a commit.

{% hint style="info" %}
Git sync is a post-deploy side effect. If the Git push fails, the pipeline deploy still succeeds — you will see a warning toast in the UI.
{% endhint %}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.71.2",
"recharts": "2.15.4",
"simple-git": "^3.32.3",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.5.0",
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Environment" ADD COLUMN "gitRepoUrl" TEXT,
ADD COLUMN "gitBranch" TEXT DEFAULT 'main',
ADD COLUMN "gitToken" TEXT;
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ model Environment {
enrollmentTokenHint String?
secretBackend SecretBackend @default(BUILTIN)
secretBackendConfig Json?
gitRepoUrl String?
gitBranch String? @default("main")
gitToken String? // Stored encrypted via crypto.ts
alertRules AlertRule[]
alertWebhooks AlertWebhook[]
createdAt DateTime @default(now())
Expand Down
8 changes: 8 additions & 0 deletions src/app/(dashboard)/environments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { SecretsSection } from "@/components/environment/secrets-section";
import { CertificatesSection } from "@/components/environment/certificates-section";
import { GitSyncSection } from "@/components/environment/git-sync-section";
import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status";

export default function EnvironmentDetailPage({
Expand Down Expand Up @@ -514,6 +515,13 @@ export default function EnvironmentDetailPage({
<SecretsSection environmentId={id} />
<CertificatesSection environmentId={id} />

<GitSyncSection
environmentId={id}
gitRepoUrl={env.gitRepoUrl}
gitBranch={env.gitBranch}
hasGitToken={env.hasGitToken}
/>

{/* Created info */}
<p className="text-xs text-muted-foreground">
Created {new Date(env.createdAt).toLocaleDateString()}
Expand Down
220 changes: 220 additions & 0 deletions src/components/environment/git-sync-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"use client";

import { useState } from "react";
import { useTRPC } from "@/trpc/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { GitBranch, Eye, EyeOff, Loader2 } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";

interface GitSyncSectionProps {
environmentId: string;
gitRepoUrl: string | null;
gitBranch: string | null;
hasGitToken: boolean;
}

export function GitSyncSection({
environmentId,
gitRepoUrl,
gitBranch,
hasGitToken,
}: GitSyncSectionProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();

const [repoUrl, setRepoUrl] = useState(gitRepoUrl ?? "");
const [branch, setBranch] = useState(gitBranch ?? "main");
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [isTesting, setIsTesting] = useState(false);

const updateMutation = useMutation(
trpc.environment.update.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.environment.get.queryKey({ id: environmentId }) });
},
onError: (err) => toast.error(err.message || "Failed to save Git settings"),
})
);

const testMutation = useMutation(
trpc.environment.testGitConnection.mutationOptions({
onSuccess: (result) => {
if (result.success) {
toast.success("Git connection successful");
} else {
toast.error("Git connection failed", { description: result.error });
}
setIsTesting(false);
},
onError: (err) => {
toast.error("Connection test failed", { description: err.message });
setIsTesting(false);
},
})
);

function handleSave() {
updateMutation.mutate(
{
id: environmentId,
gitRepoUrl: repoUrl || null,
gitBranch: branch || null,
gitToken: token || undefined, // Only send if user entered a new token
},
{
onSuccess: () => {
toast.success("Git integration settings saved");
setToken("");
},
},
);
}

function handleTest() {
const testToken = token || undefined;
if (!repoUrl) {
toast.error("Enter a repository URL first");
return;
}
if (!testToken && !hasGitToken) {
toast.error("Enter an access token first");
return;
}
if (testToken) {
setIsTesting(true);
testMutation.mutate({ environmentId, repoUrl, branch, token: testToken });
} else {
toast.warning("Enter a new token to test the connection");
}
}

function handleDisconnect() {
updateMutation.mutate(
{
id: environmentId,
gitRepoUrl: null,
gitBranch: null,
gitToken: null,
},
{
onSuccess: () => {
toast.success("Git integration disconnected");
setRepoUrl("");
setBranch("main");
setToken("");
},
},
);
}

const hasChanges = repoUrl !== (gitRepoUrl ?? "") || branch !== (gitBranch ?? "main") || token !== "";
const isConfigured = !!gitRepoUrl;

return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5" />
<div>
<CardTitle>Git Integration</CardTitle>
<CardDescription>
Automatically commit pipeline YAML to a Git repository on deploy and delete.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="git-repo-url">Repository URL</Label>
<Input
id="git-repo-url"
type="url"
placeholder="https://github.com/org/pipeline-configs.git"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
/>
</div>

<div className="space-y-2">
<Label htmlFor="git-branch">Branch</Label>
<Input
id="git-branch"
placeholder="main"
value={branch}
onChange={(e) => setBranch(e.target.value)}
/>
</div>

<div className="space-y-2">
<Label htmlFor="git-token">
Access Token {hasGitToken && "(saved \u2014 enter new value to replace)"}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="git-token"
type={showToken ? "text" : "password"}
placeholder={hasGitToken ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "ghp_xxxx or glpat-xxxx"}
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={() => setShowToken(!showToken)}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>

<div className="flex gap-2 pt-2">
<Button
onClick={handleSave}
disabled={updateMutation.isPending || !hasChanges}
>
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="outline"
onClick={handleTest}
disabled={isTesting || !repoUrl}
>
{isTesting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Testing...
</>
) : (
"Test Connection"
)}
</Button>
{isConfigured && (
<Button
variant="destructive"
onClick={handleDisconnect}
disabled={updateMutation.isPending}
>
Disconnect
</Button>
)}
</div>
</CardContent>
</Card>
);
}
6 changes: 6 additions & 0 deletions src/components/flow/deploy-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro
toast.success("Pipeline published to agents", {
description: result.versionNumber ? `Version v${result.versionNumber}` : undefined,
});
if (result.gitSyncError) {
toast.warning("Pipeline deployed but Git sync failed", {
description: result.gitSyncError,
duration: 8000,
});
}
onOpenChange(false);
},
onError: (err) => {
Expand Down
Loading
Loading