Merge branch 'canary' into feat/internal-path-routing

This commit is contained in:
Mauricio Siu
2025-06-21 23:39:01 -06:00
58 changed files with 24593 additions and 151 deletions

View File

@@ -121,6 +121,7 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null, updateConfigSwarm: null,
username: null, username: null,
dockerContextPath: null, dockerContextPath: null,
rollbackActive: false,
}; };
describe("unzipDrop using real zip files", () => { describe("unzipDrop using real zip files", () => {

View File

@@ -5,6 +5,7 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaRepository: "", giteaRepository: "",

View File

@@ -10,11 +10,14 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon } from "lucide-react"; import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues"; import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token"; import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment"; import { ShowDeployment } from "./show-deployment";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props { interface Props {
id: string; id: string;
@@ -57,6 +60,9 @@ export const ShowDeployments = ({
}, },
); );
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const [url, setUrl] = React.useState(""); const [url, setUrl] = React.useState("");
useEffect(() => { useEffect(() => {
setUrl(document.location.origin); setUrl(document.location.origin);
@@ -71,9 +77,18 @@ export const ShowDeployments = ({
See all the 10 last deployments for this {type} See all the 10 last deployments for this {type}
</CardDescription> </CardDescription>
</div> </div>
{(type === "application" || type === "compose") && ( <div className="flex flex-row items-center gap-2">
<CancelQueues id={id} type={type} /> {(type === "application" || type === "compose") && (
)} <CancelQueues id={id} type={type} />
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
Configure Rollbacks <Settings className="size-4" />
</Button>
</ShowRollbackSettings>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{refreshToken && ( {refreshToken && (
@@ -154,13 +169,47 @@ export const ShowDeployments = ({
)} )}
</div> </div>
<Button <div className="flex flex-row items-center gap-2">
onClick={() => { <Button
setActiveLog(deployment); onClick={() => {
}} setActiveLog(deployment);
> }}
View >
</Button> View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -49,7 +49,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
currentBuildArgs !== (data?.buildArgs || ""); currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => { useEffect(() => {
if (data) { if (data && !hasChanges) {
form.reset({ form.reset({
env: data.env || "", env: data.env || "",
buildArgs: data.buildArgs || "", buildArgs: data.buildArgs || "",

View File

@@ -16,9 +16,11 @@ import { api } from "@/utils/api";
import { GitBranch, Loader2, UploadCloud } from "lucide-react"; import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider"; import { SaveGitlabProvider } from "./save-gitlab-provider";
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
type TabState = type TabState =
| "github" | "github"
@@ -43,12 +45,31 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId }); const { data: application, refetch } = api.application.one.useQuery({
applicationId,
});
const { mutateAsync: disconnectGitProvider } =
api.application.disconnectGitProvider.useMutation();
const [tab, setSab] = useState<TabState>(application?.sourceType || "github"); const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
const isLoading = const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ applicationId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
@@ -77,6 +98,38 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
); );
} }
// Check if user doesn't have access to the current git provider
if (
application &&
!application.hasGitProviderAccess &&
application.sourceType !== "docker" &&
application.sourceType !== "drop"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={application}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>

View File

@@ -0,0 +1,149 @@
import {
BitbucketIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { RouterOutputs } from "@/utils/api";
import { AlertCircle, GitBranch, Unlink } from "lucide-react";
interface Props {
service:
| RouterOutputs["application"]["one"]
| RouterOutputs["compose"]["one"];
onDisconnect: () => void;
}
export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => {
const getProviderIcon = (sourceType: string) => {
switch (sourceType) {
case "github":
return <GithubIcon className="size-5 text-muted-foreground" />;
case "gitlab":
return <GitlabIcon className="size-5 text-muted-foreground" />;
case "bitbucket":
return <BitbucketIcon className="size-5 text-muted-foreground" />;
case "gitea":
return <GiteaIcon className="size-5 text-muted-foreground" />;
case "git":
return <GitIcon className="size-5 text-muted-foreground" />;
default:
return <GitBranch className="size-5 text-muted-foreground" />;
}
};
const getRepositoryInfo = () => {
switch (service.sourceType) {
case "github":
return {
repo: service.repository,
branch: service.branch,
owner: service.owner,
};
case "gitlab":
return {
repo: service.gitlabRepository,
branch: service.gitlabBranch,
owner: service.gitlabOwner,
};
case "bitbucket":
return {
repo: service.bitbucketRepository,
branch: service.bitbucketBranch,
owner: service.bitbucketOwner,
};
case "gitea":
return {
repo: service.giteaRepository,
branch: service.giteaBranch,
owner: service.giteaOwner,
};
case "git":
return {
repo: service.customGitUrl,
branch: service.customGitBranch,
owner: null,
};
default:
return { repo: null, branch: null, owner: null };
}
};
const { repo, branch, owner } = getRepositoryInfo();
return (
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This application is connected to a {service.sourceType} repository
through a git provider that you don't have access to. You can see
basic repository information below, but cannot modify the
configuration.
</AlertDescription>
</Alert>
<Card className="border-dashed border-2 border-muted-foreground/20 bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getProviderIcon(service.sourceType)}
<span className="capitalize text-sm font-medium">
{service.sourceType} Repository
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{owner && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Owner:
</span>
<p className="text-sm">{owner}</p>
</div>
)}
{repo && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Repository:
</span>
<p className="text-sm">{repo}</p>
</div>
)}
{branch && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Branch:
</span>
<p className="text-sm">{branch}</p>
</div>
)}
<div className="pt-4 border-t">
<DialogAction
title="Disconnect Repository"
description="Are you sure you want to disconnect this repository?"
type="default"
onClick={async () => {
onDisconnect();
}}
>
<Button variant="secondary" className="w-full">
<Unlink className="size-4 mr-2" />
Disconnect Repository
</Button>
</DialogAction>
<p className="text-xs text-muted-foreground mt-2">
Disconnecting will allow you to configure a new repository with
your own git providers.
</p>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
rollbackActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
applicationId: string;
children?: React.ReactNode;
}
export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: application, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
rollbackActive: application?.rollbackActive ?? false,
},
});
const onSubmit = async (data: FormValues) => {
await updateApplication({
applicationId,
rollbackActive: data.rollbackActive,
})
.then(() => {
toast.success("Rollback settings updated");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Failed to update rollback settings");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback Settings</DialogTitle>
<DialogDescription>
Configure how rollbacks work for this application
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="rollbackActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Rollbacks
</FormLabel>
<FormDescription>
Allow rolling back to previous deployments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Save Settings
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
resolver: zodResolver(AddComposeFile), resolver: zodResolver(AddComposeFile),
}); });
const composeFile = form.watch("composeFile");
useEffect(() => { useEffect(() => {
if (data) { if (data && !composeFile) {
form.reset({ form.reset({
composeFile: data.composeFile || "", composeFile: data.composeFile || "",
}); });

View File

@@ -18,6 +18,8 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose"; import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
import { toast } from "sonner";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props { interface Props {
@@ -34,12 +36,29 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId }); const { mutateAsync: disconnectGitProvider } =
api.compose.disconnectGitProvider.useMutation();
const { data: compose, refetch } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github"); const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
const isLoading = const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ composeId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
@@ -68,6 +87,37 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
); );
} }
// Check if user doesn't have access to the current git provider
if (
compose &&
!compose.hasGitProviderAccess &&
compose.sourceType !== "raw"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={compose}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>

View File

@@ -38,7 +38,7 @@ const AddProjectSchema = z.object({
(name) => { (name) => {
const trimmedName = name.trim(); const trimmedName = name.trim();
const validNameRegex = const validNameRegex =
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
return validNameRegex.test(trimmedName); return validNameRegex.test(trimmedName);
}, },
{ {

View File

@@ -1,80 +1,93 @@
// @ts-nocheck
export const extractExpirationDate = (certData: string): Date | null => { export const extractExpirationDate = (certData: string): Date | null => {
try { try {
const match = certData.match( // Decode PEM base64 to DER binary
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
); const binStr = atob(b64);
if (!match?.[1]) return null; const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
const base64Cert = match[1].replace(/\s/g, ""); der[i] = binStr.charCodeAt(i);
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
} }
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 let offset = 0;
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
let dateFound = false;
for (let i = 0; i < bytes.length - 2; i++) {
// Look for sequence containing validity period (0x30)
if (bytes[i] === 0x30) {
// Check next bytes for UTCTime or GeneralizedTime
let j = i + 1;
while (j < bytes.length - 2) {
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
const dateType = bytes[j];
const dateLength = bytes[j + 1];
if (typeof dateLength === "undefined") break;
if (!dateFound) { // Helper: read ASN.1 length field
// Skip "not before" date function readLength(pos: number): { length: number; offset: number } {
dateFound = true; // biome-ignore lint/style/noParameterAssign: <explanation>
j += dateLength + 2; let len = der[pos++];
continue; if (len & 0x80) {
} const bytes = len & 0x7f;
len = 0;
// Found "not after" date for (let i = 0; i < bytes; i++) {
let dateStr = ""; // biome-ignore lint/style/noParameterAssign: <explanation>
for (let k = 0; k < dateLength; k++) { len = (len << 8) + der[pos++];
const charCode = bytes[j + 2 + k];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
j++;
} }
} }
return { length: len, offset: pos };
} }
return null;
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
const validityLen = readLength(offset);
offset = validityLen.offset;
// notBefore
offset++;
const notBeforeLen = readLength(offset);
offset = notBeforeLen.offset + notBeforeLen.length;
// notAfter
offset++;
const notAfterLen = readLength(offset);
const notAfterStr = new TextDecoder().decode(
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
);
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
function parseTime(str: string): Date {
if (str.length === 13) {
// UTCTime YYMMDDhhmmssZ
const year = Number.parseInt(str.slice(0, 2), 10);
const fullYear = year < 50 ? 2000 + year : 1900 + year;
return new Date(
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
);
}
if (str.length === 15) {
// GeneralizedTime YYYYMMDDhhmmssZ
return new Date(
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
);
}
throw new Error("Invalid ASN.1 time format");
}
return parseTime(notAfterStr);
} catch (error) { } catch (error) {
console.error("Error parsing certificate:", error); console.error("Error parsing certificate:", error);
return null; return null;

View File

@@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
export const AddGithubProvider = () => { export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data } = api.user.get.useQuery(); const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState(""); const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false); const [isOrganization, setIsOrganization] = useState(false);
@@ -27,7 +28,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin; const url = document.location.origin;
const manifest = JSON.stringify( const manifest = JSON.stringify(
{ {
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`, redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
url: origin, url: origin,
hook_attributes: { hook_attributes: {

View File

@@ -41,6 +41,7 @@ const addInvitation = z.object({
.min(1, "Email is required") .min(1, "Email is required")
.email({ message: "Invalid email" }), .email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]), role: z.enum(["member", "admin"]),
notificationId: z.string().optional(),
}); });
type AddInvitation = z.infer<typeof addInvitation>; type AddInvitation = z.infer<typeof addInvitation>;
@@ -49,6 +50,10 @@ export const AddInvitation = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
@@ -56,6 +61,7 @@ export const AddInvitation = () => {
defaultValues: { defaultValues: {
email: "", email: "",
role: "member", role: "member",
notificationId: "",
}, },
resolver: zodResolver(addInvitation), resolver: zodResolver(addInvitation),
}); });
@@ -74,7 +80,20 @@ export const AddInvitation = () => {
if (result.error) { if (result.error) {
setError(result.error.message || ""); setError(result.error.message || "");
} else { } else {
toast.success("Invitation created"); if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setError(null); setError(null);
setOpen(false); setOpen(false);
} }
@@ -149,6 +168,47 @@ export const AddInvitation = () => {
); );
}} }}
/> />
{!isCloud && (
<FormField
control={form.control}
name="notificationId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an email provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{emailProviders?.map((provider) => (
<SelectItem
key={provider.notificationId}
value={provider.notificationId}
>
{provider.name}
</SelectItem>
))}
<SelectItem value="none" disabled>
None
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the email provider to send the invitation
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="flex w-full flex-row"> <DialogFooter className="flex w-full flex-row">
<Button <Button
isLoading={isLoading} isLoading={isLoading}

View File

@@ -1,7 +1,7 @@
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import Page from "./side";
import { ChatwootWidget } from "../shared/ChatwootWidget"; import { ChatwootWidget } from "../shared/ChatwootWidget";
import Page from "./side";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -0,0 +1,15 @@
ALTER TABLE "git_provider" ADD COLUMN "userId" text;--> statement-breakpoint
-- Update existing git providers to be owned by the organization owner
-- We need to get the account.user_id for the organization owner
UPDATE "git_provider"
SET "userId" = (
SELECT a.user_id
FROM "organization" o
JOIN "account" a ON o."owner_id" = a.user_id
WHERE o.id = "git_provider"."organizationId"
);--> statement-breakpoint
-- Now make the column NOT NULL since all rows should have values
ALTER TABLE "git_provider" ALTER COLUMN "userId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,13 @@
CREATE TABLE "rollback" (
"rollbackId" text PRIMARY KEY NOT NULL,
"deploymentId" text NOT NULL,
"version" serial NOT NULL,
"image" text,
"createdAt" text NOT NULL,
"fullContext" jsonb
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "rollbackActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "rollbackId" text;--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_rollbackId_rollback_rollbackId_fk" FOREIGN KEY ("rollbackId") REFERENCES "public"."rollback"("rollbackId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -659,6 +659,34 @@
"when": 1750397258622, "when": 1750397258622,
"tag": "0093_nice_gorilla_man", "tag": "0093_nice_gorilla_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1750559214977,
"tag": "0094_numerous_carmella_unuscione",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1750562292392,
"tag": "0095_curly_justice",
"breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1750566830268,
"tag": "0096_small_shaman",
"breakpoints": true
},
{
"idx": 97,
"version": "7",
"when": 1750567641441,
"tag": "0097_hard_lizard",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,13 +10,14 @@ type Query = {
state: string; state: string;
installation_id: string; installation_id: string;
setup_action: string; setup_action: string;
userId: string;
}; };
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
) { ) {
const { code, state, installation_id }: Query = req.query as Query; const { code, state, installation_id, userId }: Query = req.query as Query;
if (!code) { if (!code) {
return res.status(400).json({ error: "Missing code parameter" }); return res.status(400).json({ error: "Missing code parameter" });
@@ -44,6 +45,7 @@ export default async function handler(
githubPrivateKey: data.pem, githubPrivateKey: data.pem,
}, },
value as string, value as string,
userId,
); );
} else if (action === "gh_setup") { } else if (action === "gh_setup") {
await db await db

View File

@@ -74,12 +74,7 @@ const Service = (
} }
}, [router.query.tab]); }, [router.query.tab]);
const { data } = api.compose.one.useQuery( const { data } = api.compose.one.useQuery({ composeId });
{ composeId },
{
refetchInterval: 5000,
},
);
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();

View File

@@ -28,7 +28,6 @@ import { projectRouter } from "./routers/project";
import { redirectsRouter } from "./routers/redirects"; import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis"; import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry"; import { registryRouter } from "./routers/registry";
import { scheduleRouter } from "./routers/schedule";
import { securityRouter } from "./routers/security"; import { securityRouter } from "./routers/security";
import { serverRouter } from "./routers/server"; import { serverRouter } from "./routers/server";
import { settingsRouter } from "./routers/settings"; import { settingsRouter } from "./routers/settings";
@@ -36,6 +35,8 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe"; import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm"; import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { scheduleRouter } from "./routers/schedule";
import { rollbackRouter } from "./routers/rollbacks";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
* *
@@ -80,6 +81,7 @@ export const appRouter = createTRPCRouter({
ai: aiRouter, ai: aiRouter,
organization: organizationRouter, organization: organizationRouter,
schedule: scheduleRouter, schedule: scheduleRouter,
rollback: rollbackRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -31,6 +31,7 @@ import {
createApplication, createApplication,
deleteAllMiddlewares, deleteAllMiddlewares,
findApplicationById, findApplicationById,
findGitProviderById,
findProjectById, findProjectById,
getApplicationStats, getApplicationStats,
mechanizeDockerContainer, mechanizeDockerContainer,
@@ -126,7 +127,45 @@ export const applicationRouter = createTRPCRouter({
message: "You are not authorized to access this application", message: "You are not authorized to access this application",
}); });
} }
return application;
let hasGitProviderAccess = true;
let unauthorizedProvider: string | null = null;
const getGitProviderId = () => {
switch (application.sourceType) {
case "github":
return application.github?.gitProviderId;
case "gitlab":
return application.gitlab?.gitProviderId;
case "bitbucket":
return application.bitbucket?.gitProviderId;
case "gitea":
return application.gitea?.gitProviderId;
default:
return null;
}
};
const gitProviderId = getGitProviderId();
if (gitProviderId) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
}
return {
...application,
hasGitProviderAccess,
unauthorizedProvider,
};
}), }),
reload: protectedProcedure reload: protectedProcedure
@@ -488,6 +527,67 @@ export const applicationRouter = createTRPCRouter({
enableSubmodules: input.enableSubmodules, enableSubmodules: input.enableSubmodules,
}); });
return true;
}),
disconnectGitProvider: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.project.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to disconnect this git provider",
});
}
// Reset all git provider related fields
await updateApplication(input.applicationId, {
// GitHub fields
repository: null,
branch: null,
owner: null,
buildPath: "/",
githubId: null,
triggerType: "push",
// GitLab fields
gitlabRepository: null,
gitlabOwner: null,
gitlabBranch: null,
gitlabBuildPath: null,
gitlabId: null,
gitlabProjectId: null,
gitlabPathNamespace: null,
// Bitbucket fields
bitbucketRepository: null,
bitbucketOwner: null,
bitbucketBranch: null,
bitbucketBuildPath: null,
bitbucketId: null,
// Gitea fields
giteaRepository: null,
giteaOwner: null,
giteaBranch: null,
giteaBuildPath: null,
giteaId: null,
// Custom Git fields
customGitBranch: null,
customGitBuildPath: null,
customGitUrl: null,
customGitSSHKeyId: null,
// Common fields
sourceType: "github", // Reset to default
applicationStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
return true; return true;
}), }),
markRunning: protectedProcedure markRunning: protectedProcedure

View File

@@ -22,6 +22,7 @@ import {
findPostgresByBackupId, findPostgresByBackupId,
findPostgresById, findPostgresById,
findServerById, findServerById,
keepLatestNBackups,
removeBackupById, removeBackupById,
removeScheduleBackup, removeScheduleBackup,
runMariadbBackup, runMariadbBackup,
@@ -197,6 +198,8 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const postgres = await findPostgresByBackupId(backup.backupId); const postgres = await findPostgresByBackupId(backup.backupId);
await runPostgresBackup(postgres, backup); await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres?.serverId);
return true; return true;
} catch (error) { } catch (error) {
const message = const message =
@@ -217,6 +220,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mysql = await findMySqlByBackupId(backup.backupId); const mysql = await findMySqlByBackupId(backup.backupId);
await runMySqlBackup(mysql, backup); await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -233,6 +237,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mariadb = await findMariadbByBackupId(backup.backupId); const mariadb = await findMariadbByBackupId(backup.backupId);
await runMariadbBackup(mariadb, backup); await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -249,6 +254,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const compose = await findComposeByBackupId(backup.backupId); const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup); await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -265,6 +271,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mongo = await findMongoByBackupId(backup.backupId); const mongo = await findMongoByBackupId(backup.backupId);
await runMongoBackup(mongo, backup); await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -22,7 +22,11 @@ export const bitbucketRouter = createTRPCRouter({
.input(apiCreateBitbucket) .input(apiCreateBitbucket)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createBitbucket(input, ctx.session.activeOrganizationId); return await createBitbucket(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -37,7 +41,8 @@ export const bitbucketRouter = createTRPCRouter({
const bitbucketProvider = await findBitbucketById(input.bitbucketId); const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if ( if (
bitbucketProvider.gitProvider.organizationId !== bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -56,11 +61,13 @@ export const bitbucketRouter = createTRPCRouter({
}, },
}); });
result = result.filter( result = result.filter((provider) => {
(provider) => return (
provider.gitProvider.organizationId === provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId &&
); provider.gitProvider.userId === ctx.session.userId
);
});
return result; return result;
}), }),
@@ -70,7 +77,8 @@ export const bitbucketRouter = createTRPCRouter({
const bitbucketProvider = await findBitbucketById(input.bitbucketId); const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if ( if (
bitbucketProvider.gitProvider.organizationId !== bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -87,7 +95,8 @@ export const bitbucketRouter = createTRPCRouter({
); );
if ( if (
bitbucketProvider.gitProvider.organizationId !== bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -103,7 +112,8 @@ export const bitbucketRouter = createTRPCRouter({
const bitbucketProvider = await findBitbucketById(input.bitbucketId); const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if ( if (
bitbucketProvider.gitProvider.organizationId !== bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -126,7 +136,8 @@ export const bitbucketRouter = createTRPCRouter({
const bitbucketProvider = await findBitbucketById(input.bitbucketId); const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if ( if (
bitbucketProvider.gitProvider.organizationId !== bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",

View File

@@ -28,6 +28,7 @@ import {
deleteMount, deleteMount,
findComposeById, findComposeById,
findDomainsByComposeId, findDomainsByComposeId,
findGitProviderById,
findProjectById, findProjectById,
findServerById, findServerById,
findUserById, findUserById,
@@ -119,7 +120,45 @@ export const composeRouter = createTRPCRouter({
message: "You are not authorized to access this compose", message: "You are not authorized to access this compose",
}); });
} }
return compose;
let hasGitProviderAccess = true;
let unauthorizedProvider: string | null = null;
const getGitProviderId = () => {
switch (compose.sourceType) {
case "github":
return compose.github?.gitProviderId;
case "gitlab":
return compose.gitlab?.gitProviderId;
case "bitbucket":
return compose.bitbucket?.gitProviderId;
case "gitea":
return compose.gitea?.gitProviderId;
default:
return null;
}
};
const gitProviderId = getGitProviderId();
if (gitProviderId) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
}
return {
...compose,
hasGitProviderAccess,
unauthorizedProvider,
};
}), }),
update: protectedProcedure update: protectedProcedure
@@ -526,6 +565,61 @@ export const composeRouter = createTRPCRouter({
const uniqueTags = _.uniq(allTags); const uniqueTags = _.uniq(allTags);
return uniqueTags; return uniqueTags;
}), }),
disconnectGitProvider: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to disconnect this git provider",
});
}
// Reset all git provider related fields
await updateCompose(input.composeId, {
// GitHub fields
repository: null,
branch: null,
owner: null,
composePath: undefined,
githubId: null,
triggerType: "push",
// GitLab fields
gitlabRepository: null,
gitlabOwner: null,
gitlabBranch: null,
gitlabId: null,
gitlabProjectId: null,
gitlabPathNamespace: null,
// Bitbucket fields
bitbucketRepository: null,
bitbucketOwner: null,
bitbucketBranch: null,
bitbucketId: null,
// Gitea fields
giteaRepository: null,
giteaOwner: null,
giteaBranch: null,
giteaId: null,
// Custom Git fields
customGitBranch: null,
customGitUrl: null,
customGitSSHKeyId: null,
// Common fields
sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
return true;
}),
move: protectedProcedure move: protectedProcedure
.input( .input(

View File

@@ -65,7 +65,11 @@ export const deploymentRouter = createTRPCRouter({
const deploymentsList = await db.query.deployments.findMany({ const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id), where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt), orderBy: desc(deployments.createdAt),
with: {
rollback: true,
},
}); });
return deploymentsList; return deploymentsList;
}), }),
}); });

View File

@@ -3,7 +3,7 @@ import { db } from "@/server/db";
import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema"; import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema";
import { findGitProviderById, removeGitProvider } from "@dokploy/server"; import { findGitProviderById, removeGitProvider } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
export const gitProviderRouter = createTRPCRouter({ export const gitProviderRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
@@ -15,7 +15,10 @@ export const gitProviderRouter = createTRPCRouter({
gitea: true, gitea: true,
}, },
orderBy: desc(gitProvider.createdAt), orderBy: desc(gitProvider.createdAt),
where: eq(gitProvider.organizationId, ctx.session.activeOrganizationId), where: and(
eq(gitProvider.userId, ctx.session.userId),
eq(gitProvider.organizationId, ctx.session.activeOrganizationId),
),
}); });
}), }),
remove: protectedProcedure remove: protectedProcedure

View File

@@ -26,7 +26,11 @@ export const giteaRouter = createTRPCRouter({
.input(apiCreateGitea) .input(apiCreateGitea)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createGitea(input, ctx.session.activeOrganizationId); return await createGitea(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -42,7 +46,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(input.giteaId); const giteaProvider = await findGiteaById(input.giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -62,7 +67,8 @@ export const giteaRouter = createTRPCRouter({
result = result.filter( result = result.filter(
(provider) => (provider) =>
provider.gitProvider.organizationId === provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
); );
const filtered = result const filtered = result
@@ -94,7 +100,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -130,7 +137,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -162,7 +170,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -190,7 +199,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(input.giteaId); const giteaProvider = await findGiteaById(input.giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -231,7 +241,8 @@ export const giteaRouter = createTRPCRouter({
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
if ( if (
giteaProvider.gitProvider.organizationId !== giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",

View File

@@ -21,7 +21,8 @@ export const githubRouter = createTRPCRouter({
const githubProvider = await findGithubById(input.githubId); const githubProvider = await findGithubById(input.githubId);
if ( if (
githubProvider.gitProvider.organizationId !== githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -36,7 +37,8 @@ export const githubRouter = createTRPCRouter({
const githubProvider = await findGithubById(input.githubId); const githubProvider = await findGithubById(input.githubId);
if ( if (
githubProvider.gitProvider.organizationId !== githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -51,7 +53,8 @@ export const githubRouter = createTRPCRouter({
const githubProvider = await findGithubById(input.githubId || ""); const githubProvider = await findGithubById(input.githubId || "");
if ( if (
githubProvider.gitProvider.organizationId !== githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) { ) {
//TODO: Remove this line when the cloud version is ready //TODO: Remove this line when the cloud version is ready
throw new TRPCError({ throw new TRPCError({
@@ -71,7 +74,8 @@ export const githubRouter = createTRPCRouter({
result = result.filter( result = result.filter(
(provider) => (provider) =>
provider.gitProvider.organizationId === provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
); );
const filtered = result const filtered = result
@@ -95,7 +99,8 @@ export const githubRouter = createTRPCRouter({
const githubProvider = await findGithubById(input.githubId); const githubProvider = await findGithubById(input.githubId);
if ( if (
githubProvider.gitProvider.organizationId !== githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -117,7 +122,8 @@ export const githubRouter = createTRPCRouter({
const githubProvider = await findGithubById(input.githubId); const githubProvider = await findGithubById(input.githubId);
if ( if (
githubProvider.gitProvider.organizationId !== githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",

View File

@@ -25,7 +25,11 @@ export const gitlabRouter = createTRPCRouter({
.input(apiCreateGitlab) .input(apiCreateGitlab)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createGitlab(input, ctx.session.activeOrganizationId); return await createGitlab(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -40,7 +44,8 @@ export const gitlabRouter = createTRPCRouter({
const gitlabProvider = await findGitlabById(input.gitlabId); const gitlabProvider = await findGitlabById(input.gitlabId);
if ( if (
gitlabProvider.gitProvider.organizationId !== gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -56,11 +61,13 @@ export const gitlabRouter = createTRPCRouter({
}, },
}); });
result = result.filter( result = result.filter((provider) => {
(provider) => return (
provider.gitProvider.organizationId === provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId &&
); provider.gitProvider.userId === ctx.session.userId
);
});
const filtered = result const filtered = result
.filter((provider) => haveGitlabRequirements(provider)) .filter((provider) => haveGitlabRequirements(provider))
.map((provider) => { .map((provider) => {
@@ -80,7 +87,8 @@ export const gitlabRouter = createTRPCRouter({
const gitlabProvider = await findGitlabById(input.gitlabId); const gitlabProvider = await findGitlabById(input.gitlabId);
if ( if (
gitlabProvider.gitProvider.organizationId !== gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -96,7 +104,8 @@ export const gitlabRouter = createTRPCRouter({
const gitlabProvider = await findGitlabById(input.gitlabId || ""); const gitlabProvider = await findGitlabById(input.gitlabId || "");
if ( if (
gitlabProvider.gitProvider.organizationId !== gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -112,7 +121,8 @@ export const gitlabRouter = createTRPCRouter({
const gitlabProvider = await findGitlabById(input.gitlabId || ""); const gitlabProvider = await findGitlabById(input.gitlabId || "");
if ( if (
gitlabProvider.gitProvider.organizationId !== gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -135,7 +145,8 @@ export const gitlabRouter = createTRPCRouter({
const gitlabProvider = await findGitlabById(input.gitlabId); const gitlabProvider = await findGitlabById(input.gitlabId);
if ( if (
gitlabProvider.gitProvider.organizationId !== gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",

View File

@@ -446,4 +446,12 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
},
});
}),
}); });

View File

@@ -0,0 +1,37 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { apiFindOneRollback } from "@/server/db/schema";
import { removeRollbackById, rollback } from "@dokploy/server";
import { TRPCError } from "@trpc/server";
export const rollbackRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneRollback)
.mutation(async ({ input }) => {
try {
return removeRollbackById(input.rollbackId);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Error input: Deleting rollback";
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
}),
rollback: protectedProcedure
.input(apiFindOneRollback)
.mutation(async ({ input }) => {
try {
return await rollback(input.rollbackId);
} catch (error) {
console.error(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Rolling back",
cause: error,
});
}
}),
});

View File

@@ -1,10 +1,13 @@
import { import {
IS_CLOUD, IS_CLOUD,
createApiKey, createApiKey,
findAdmin,
findNotificationById,
findOrganizationById, findOrganizationById,
findUserById, findUserById,
getUserByToken, getUserByToken,
removeUserById, removeUserById,
sendEmailNotification,
updateUser, updateUser,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
@@ -362,4 +365,59 @@ export const userRouter = createTRPCRouter({
return organizations.length; return organizations.length;
}), }),
sendInvitation: adminProcedure
.input(
z.object({
invitationId: z.string().min(1),
notificationId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return;
}
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
} catch (error) {
console.log(error);
throw error;
}
return inviteLink;
}),
}); });

View File

@@ -15,7 +15,7 @@ const config = {
center: true, center: true,
padding: "2rem", padding: "2rem",
screens: { screens: {
"2xl": "1400px", "2xl": "87.5rem",
}, },
}, },
extend: { extend: {

View File

@@ -27,7 +27,6 @@ import { server } from "./server";
import { applicationStatus, certificateType, triggerType } from "./shared"; import { applicationStatus, certificateType, triggerType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [ export const sourceType = pgEnum("sourceType", [
"docker", "docker",
"git", "git",
@@ -132,6 +131,7 @@ export const applications = pgTable("application", {
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default( isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false, false,
), ),
rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"), buildArgs: text("buildArgs"),
memoryReservation: text("memoryReservation"), memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"), memoryLimit: text("memoryLimit"),

View File

@@ -15,6 +15,7 @@ import { compose } from "./compose";
import { previewDeployments } from "./preview-deployments"; import { previewDeployments } from "./preview-deployments";
import { schedules } from "./schedule"; import { schedules } from "./schedule";
import { server } from "./server"; import { server } from "./server";
import { rollbacks } from "./rollbacks";
export const deploymentStatus = pgEnum("deploymentStatus", [ export const deploymentStatus = pgEnum("deploymentStatus", [
"running", "running",
"done", "done",
@@ -58,6 +59,10 @@ export const deployments = pgTable("deployment", {
backupId: text("backupId").references((): AnyPgColumn => backups.backupId, { backupId: text("backupId").references((): AnyPgColumn => backups.backupId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
rollbackId: text("rollbackId").references(
(): AnyPgColumn => rollbacks.rollbackId,
{ onDelete: "cascade" },
),
}); });
export const deploymentsRelations = relations(deployments, ({ one }) => ({ export const deploymentsRelations = relations(deployments, ({ one }) => ({
@@ -85,6 +90,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.backupId], fields: [deployments.backupId],
references: [backups.backupId], references: [backups.backupId],
}), }),
rollback: one(rollbacks, {
fields: [deployments.deploymentId],
references: [rollbacks.deploymentId],
}),
})); }));
const schema = createInsertSchema(deployments, { const schema = createInsertSchema(deployments, {

View File

@@ -8,6 +8,7 @@ import { bitbucket } from "./bitbucket";
import { gitea } from "./gitea"; import { gitea } from "./gitea";
import { github } from "./github"; import { github } from "./github";
import { gitlab } from "./gitlab"; import { gitlab } from "./gitlab";
import { users_temp } from "./user";
export const gitProviderType = pgEnum("gitProviderType", [ export const gitProviderType = pgEnum("gitProviderType", [
"github", "github",
@@ -29,6 +30,9 @@ export const gitProvider = pgTable("git_provider", {
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
userId: text("userId")
.notNull()
.references(() => users_temp.id, { onDelete: "cascade" }),
}); });
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
@@ -52,6 +56,10 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
fields: [gitProvider.organizationId], fields: [gitProvider.organizationId],
references: [organization.id], references: [organization.id],
}), }),
user: one(users_temp, {
fields: [gitProvider.userId],
references: [users_temp.id],
}),
})); }));
const createSchema = createInsertSchema(gitProvider); const createSchema = createInsertSchema(gitProvider);

View File

@@ -32,3 +32,4 @@ export * from "./preview-deployments";
export * from "./ai"; export * from "./ai";
export * from "./account"; export * from "./account";
export * from "./schedule"; export * from "./schedule";
export * from "./rollbacks";

View File

@@ -0,0 +1,45 @@
import { relations } from "drizzle-orm";
import { jsonb, pgTable, serial, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { deployments } from "./deployment";
export const rollbacks = pgTable("rollback", {
rollbackId: text("rollbackId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
deploymentId: text("deploymentId")
.notNull()
.references(() => deployments.deploymentId, {
onDelete: "cascade",
}),
version: serial(),
image: text("image"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
fullContext: jsonb("fullContext"),
});
export type Rollback = typeof rollbacks.$inferSelect;
export const rollbacksRelations = relations(rollbacks, ({ one }) => ({
deployment: one(deployments, {
fields: [rollbacks.deploymentId],
references: [deployments.deploymentId],
}),
}));
export const createRollbackSchema = createInsertSchema(rollbacks).extend({
appName: z.string().min(1),
});
export const updateRollbackSchema = createRollbackSchema.extend({
rollbackId: z.string().min(1),
});
export const apiFindOneRollback = z.object({
rollbackId: z.string().min(1),
});

View File

@@ -32,6 +32,7 @@ export * from "./services/gitea";
export * from "./services/server"; export * from "./services/server";
export * from "./services/schedule"; export * from "./services/schedule";
export * from "./services/application"; export * from "./services/application";
export * from "./services/rollbacks";
export * from "./utils/databases/rebuild"; export * from "./utils/databases/rebuild";
export * from "./setup/config-paths"; export * from "./setup/config-paths";
export * from "./setup/postgres-setup"; export * from "./setup/postgres-setup";

View File

@@ -60,6 +60,7 @@ import {
updatePreviewDeployment, updatePreviewDeployment,
} from "./preview-deployment"; } from "./preview-deployment";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
import { createRollback } from "./rollbacks";
export type Application = typeof applications.$inferSelect; export type Application = typeof applications.$inferSelect;
export const createApplication = async ( export const createApplication = async (
@@ -214,6 +215,17 @@ export const deployApplication = async ({
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
await updateApplicationStatus(applicationId, "done"); await updateApplicationStatus(applicationId, "done");
if (application.rollbackActive) {
const tagImage =
application.sourceType === "docker"
? application.dockerImage
: application.appName;
await createRollback({
appName: tagImage || "",
deploymentId: deployment.deploymentId,
});
}
await sendBuildSuccessNotifications({ await sendBuildSuccessNotifications({
projectName: application.project.name, projectName: application.project.name,
applicationName: application.name, applicationName: application.name,
@@ -338,6 +350,17 @@ export const deployRemoteApplication = async ({
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
await updateApplicationStatus(applicationId, "done"); await updateApplicationStatus(applicationId, "done");
if (application.rollbackActive) {
const tagImage =
application.sourceType === "docker"
? application.dockerImage
: application.appName;
await createRollback({
appName: tagImage || "",
deploymentId: deployment.deploymentId,
});
}
await sendBuildSuccessNotifications({ await sendBuildSuccessNotifications({
projectName: application.project.name, projectName: application.project.name,
applicationName: application.name, applicationName: application.name,

View File

@@ -13,6 +13,7 @@ export type Bitbucket = typeof bitbucket.$inferSelect;
export const createBitbucket = async ( export const createBitbucket = async (
input: typeof apiCreateBitbucket._type, input: typeof apiCreateBitbucket._type,
organizationId: string, organizationId: string,
userId: string,
) => { ) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const newGitProvider = await tx const newGitProvider = await tx
@@ -21,6 +22,7 @@ export const createBitbucket = async (
providerType: "bitbucket", providerType: "bitbucket",
organizationId: organizationId, organizationId: organizationId,
name: input.name, name: input.name,
userId: userId,
}) })
.returning() .returning()
.then((response) => response[0]); .then((response) => response[0]);

View File

@@ -31,23 +31,38 @@ import {
updatePreviewDeployment, updatePreviewDeployment,
} from "./preview-deployment"; } from "./preview-deployment";
import { findScheduleById } from "./schedule"; import { findScheduleById } from "./schedule";
import { removeRollbackById } from "./rollbacks";
export type Deployment = typeof deployments.$inferSelect; export type Deployment = typeof deployments.$inferSelect;
export const findDeploymentById = async (applicationId: string) => { export const findDeploymentById = async (deploymentId: string) => {
const application = await db.query.deployments.findFirst({ const deployment = await db.query.deployments.findFirst({
where: eq(deployments.applicationId, applicationId), where: eq(deployments.deploymentId, deploymentId),
with: { with: {
application: true, application: true,
}, },
}); });
if (!application) { if (!deployment) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Deployment not found", message: "Deployment not found",
}); });
} }
return application; return deployment;
};
export const findDeploymentByApplicationId = async (applicationId: string) => {
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.applicationId, applicationId),
});
if (!deployment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Deployment not found",
});
}
return deployment;
}; };
export const createDeployment = async ( export const createDeployment = async (
@@ -481,6 +496,9 @@ const getDeploymentsByType = async (
const deploymentList = await db.query.deployments.findMany({ const deploymentList = await db.query.deployments.findMany({
where: eq(deployments[`${type}Id`], id), where: eq(deployments[`${type}Id`], id),
orderBy: desc(deployments.createdAt), orderBy: desc(deployments.createdAt),
with: {
rollback: true,
},
}); });
return deploymentList; return deploymentList;
}; };
@@ -515,6 +533,9 @@ const removeLastTenDeployments = async (
let command = ""; let command = "";
for (const oldDeployment of deploymentsToDelete) { for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath); const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
command += ` command += `
rm -rf ${logPath}; rm -rf ${logPath};
@@ -525,8 +546,11 @@ const removeLastTenDeployments = async (
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} else { } else {
for (const oldDeployment of deploymentsToDelete) { for (const oldDeployment of deploymentsToDelete) {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
const logPath = path.join(oldDeployment.logPath); const logPath = path.join(oldDeployment.logPath);
if (existsSync(logPath)) { if (existsSync(logPath) && !oldDeployment.errorMessage) {
await fsPromises.unlink(logPath); await fsPromises.unlink(logPath);
} }
await removeDeployment(oldDeployment.deploymentId); await removeDeployment(oldDeployment.deploymentId);

View File

@@ -12,6 +12,7 @@ export type Gitea = typeof gitea.$inferSelect;
export const createGitea = async ( export const createGitea = async (
input: typeof apiCreateGitea._type, input: typeof apiCreateGitea._type,
organizationId: string, organizationId: string,
userId: string,
) => { ) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const newGitProvider = await tx const newGitProvider = await tx
@@ -20,6 +21,7 @@ export const createGitea = async (
providerType: "gitea", providerType: "gitea",
organizationId: organizationId, organizationId: organizationId,
name: input.name, name: input.name,
userId: userId,
}) })
.returning() .returning()
.then((response) => response[0]); .then((response) => response[0]);

View File

@@ -13,6 +13,7 @@ export type Github = typeof github.$inferSelect;
export const createGithub = async ( export const createGithub = async (
input: typeof apiCreateGithub._type, input: typeof apiCreateGithub._type,
organizationId: string, organizationId: string,
userId: string,
) => { ) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const newGitProvider = await tx const newGitProvider = await tx
@@ -21,6 +22,7 @@ export const createGithub = async (
providerType: "github", providerType: "github",
organizationId: organizationId, organizationId: organizationId,
name: input.name, name: input.name,
userId: userId,
}) })
.returning() .returning()
.then((response) => response[0]); .then((response) => response[0]);

View File

@@ -12,6 +12,7 @@ export type Gitlab = typeof gitlab.$inferSelect;
export const createGitlab = async ( export const createGitlab = async (
input: typeof apiCreateGitlab._type, input: typeof apiCreateGitlab._type,
organizationId: string, organizationId: string,
userId: string,
) => { ) => {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const newGitProvider = await tx const newGitProvider = await tx
@@ -20,6 +21,7 @@ export const createGitlab = async (
providerType: "gitlab", providerType: "gitlab",
organizationId: organizationId, organizationId: organizationId,
name: input.name, name: input.name,
userId: userId,
}) })
.returning() .returning()
.then((response) => response[0]); .then((response) => response[0]);

View File

@@ -0,0 +1,191 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import {
type createRollbackSchema,
rollbacks,
deployments as deploymentsSchema,
} from "../db/schema";
import type { z } from "zod";
import { findApplicationById } from "./application";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import type { ApplicationNested } from "../utils/builders";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import type { CreateServiceOptions } from "dockerode";
import { findDeploymentById } from "./deployment";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
) => {
await db.transaction(async (tx) => {
const rollback = await tx
.insert(rollbacks)
.values(input)
.returning()
.then((res) => res[0]);
if (!rollback) {
throw new Error("Failed to create rollback");
}
const tagImage = `${input.appName}:v${rollback.version}`;
const deployment = await findDeploymentById(rollback.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const {
deployments: _,
bitbucket,
github,
gitlab,
gitea,
...rest
} = await findApplicationById(deployment.applicationId);
await tx
.update(rollbacks)
.set({
image: tagImage,
fullContext: JSON.stringify(rest),
})
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
// Update the deployment to reference this rollback
await tx
.update(deploymentsSchema)
.set({
rollbackId: rollback.rollbackId,
})
.where(eq(deploymentsSchema.deploymentId, rollback.deploymentId));
await createRollbackImage(rest, tagImage);
return rollback;
});
};
const findRollbackById = async (rollbackId: string) => {
const result = await db.query.rollbacks.findFirst({
where: eq(rollbacks.rollbackId, rollbackId),
});
if (!result) {
throw new Error("Rollback not found");
}
return result;
};
const createRollbackImage = async (
application: ApplicationNested,
tagImage: string,
) => {
const docker = await getRemoteDocker(application.serverId);
const appTagName =
application.sourceType === "docker"
? application.dockerImage
: `${application.appName}:latest`;
const result = docker.getImage(appTagName || "");
const [repo, version] = tagImage.split(":");
await result.tag({
repo,
tag: version,
});
};
const deleteRollbackImage = async (image: string, serverId?: string | null) => {
const command = `docker image rm ${image} --force`;
if (serverId) {
await execAsyncRemote(command, serverId);
} else {
await execAsync(command);
}
};
export const removeRollbackById = async (rollbackId: string) => {
const rollback = await findRollbackById(rollbackId);
if (!rollback) {
throw new Error("Rollback not found");
}
if (rollback?.image) {
try {
const deployment = await findDeploymentById(rollback.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const application = await findApplicationById(deployment.applicationId);
await deleteRollbackImage(rollback.image, application.serverId);
await db
.delete(rollbacks)
.where(eq(rollbacks.rollbackId, rollbackId))
.returning()
.then((res) => res[0]);
} catch (error) {
console.error(error);
}
}
return rollback;
};
export const rollback = async (rollbackId: string) => {
const result = await findRollbackById(rollbackId);
const deployment = await findDeploymentById(result.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const application = await findApplicationById(deployment.applicationId);
await rollbackApplication(
application.appName,
result.image || "",
application.serverId,
);
};
const rollbackApplication = async (
appName: string,
image: string,
serverId?: string | null,
) => {
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: appName,
TaskTemplate: {
ContainerSpec: {
Image: image,
},
},
};
try {
const service = docker.getService(appName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
},
});
} catch (_error: unknown) {
await docker.createService(settings);
}
};

View File

@@ -116,7 +116,7 @@ if [ "$OS_TYPE" = 'amzn' ]; then
fi fi
case "$OS_TYPE" in case "$OS_TYPE" in
arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine) ;; arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | opencloudos | amzn | alpine) ;;
*) *)
echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now." echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now."
exit exit
@@ -367,7 +367,7 @@ const installUtilities = () => `
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
;; ;;
centos | fedora | rhel | ol | rocky | almalinux | amzn) centos | fedora | rhel | ol | rocky | almalinux | opencloudos | amzn)
if [ "$OS_TYPE" = "amzn" ]; then if [ "$OS_TYPE" = "amzn" ]; then
dnf install -y wget git git-lfs jq openssl >/dev/null dnf install -y wget git git-lfs jq openssl >/dev/null
else else
@@ -418,6 +418,16 @@ if ! [ -x "$(command -v docker)" ]; then
systemctl start docker >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1
;; ;;
"opencloudos")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"alpine") "alpine")
apk add docker docker-cli-compose >/dev/null 2>&1 apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1

View File

@@ -212,7 +212,7 @@ export const cleanUpDockerBuilder = async (serverId?: string) => {
}; };
export const cleanUpSystemPrune = async (serverId?: string) => { export const cleanUpSystemPrune = async (serverId?: string) => {
const command = "docker system prune --all --force --volumes"; const command = "docker system prune --force --volumes";
if (serverId) { if (serverId) {
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} else { } else {

View File

@@ -73,7 +73,7 @@ export const cloneBitbucketRepository = async (
}); });
writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -155,7 +155,7 @@ export const getGiteaCloneCommand = async (
const cloneCommand = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath}; echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath};
exit 1; exit 1;
@@ -232,7 +232,7 @@ export const cloneGiteaRepository = async (
); );
writeStream.write(`\nCloned ${repoClone}: ✅\n`); writeStream.write(`\nCloned ${repoClone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -149,7 +149,7 @@ export const cloneGithubRepository = async ({
}); });
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -132,7 +132,7 @@ export const cloneGitlabRepository = async (
const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`; const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`;
try { try {
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
const cloneArgs = [ const cloneArgs = [
"clone", "clone",
"--branch", "--branch",
@@ -152,7 +152,7 @@ export const cloneGitlabRepository = async (
}); });
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();