Files
dokploy/packages/server/src/utils/providers/gitlab.ts
2026-03-09 00:32:23 -06:00

336 lines
8.4 KiB
TypeScript

import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { apiGitlabTestConnection } from "@dokploy/server/db/schema";
import {
findGitlabById,
type Gitlab,
updateGitlab,
} from "@dokploy/server/services/gitlab";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
const currentTime = Math.floor(Date.now() / 1000);
const safetyMargin = 60;
if (
gitlabProvider.expiresAt &&
currentTime + safetyMargin < gitlabProvider.expiresAt
) {
return;
}
// Use internal URL for token refresh when GitLab is on same instance as Dokploy
const baseUrl = gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl;
const response = await fetch(`${baseUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: gitlabProvider.refreshToken as string,
client_id: gitlabProvider.applicationId as string,
client_secret: gitlabProvider.secret as string,
}),
});
if (!response.ok) {
throw new Error(`Failed to refresh token: ${response.statusText}`);
}
const data = await response.json();
const expiresAt = Math.floor(Date.now() / 1000) + data.expires_in;
await updateGitlab(gitlabProviderId, {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt,
});
return data;
};
export const haveGitlabRequirements = (gitlabProvider: Gitlab) => {
return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken);
};
const getErrorCloneRequirements = (entity: {
gitlabRepository?: string | null;
gitlabOwner?: string | null;
gitlabBranch?: string | null;
gitlabPathNamespace?: string | null;
}) => {
const reasons: string[] = [];
const { gitlabBranch, gitlabOwner, gitlabRepository, gitlabPathNamespace } =
entity;
if (!gitlabRepository) reasons.push("1. Repository not assigned.");
if (!gitlabOwner) reasons.push("2. Owner not specified.");
if (!gitlabBranch) reasons.push("3. Branch not defined.");
if (!gitlabPathNamespace) reasons.push("4. Path namespace not defined.");
return reasons;
};
export type ApplicationWithGitlab = InferResultType<
"applications",
{ gitlab: true }
>;
export type ComposeWithGitlab = InferResultType<"compose", { gitlab: true }>;
export type GitlabInfo =
| ApplicationWithGitlab["gitlab"]
| ComposeWithGitlab["gitlab"];
const getGitlabRepoClone = (
gitlab: GitlabInfo,
gitlabPathNamespace: string | null,
) => {
const repoClone = `${gitlab?.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
return repoClone;
};
const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => {
const isSecure = gitlab?.gitlabUrl.startsWith("https://");
const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`;
return cloneUrl;
};
interface CloneGitlabRepository {
appName: string;
gitlabBranch: string | null;
gitlabId: string | null;
gitlabPathNamespace: string | null;
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
}
export const cloneGitlabRepository = async ({
type = "application",
...entity
}: CloneGitlabRepository) => {
let command = "set -e;";
const {
appName,
gitlabBranch,
gitlabId,
gitlabPathNamespace,
enableSubmodules,
serverId,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
if (!gitlabId) {
command += `echo "Error: ❌ Gitlab Provider not found"; exit 1;`;
return command;
}
await refreshGitlabToken(gitlabId);
const gitlab = await findGitlabById(gitlabId);
const requirements = getErrorCloneRequirements(entity);
// Check if requirements are met
if (requirements.length > 0) {
command += `echo "❌ [ERROR] GitLab Repository configuration failed for application: ${appName}"; echo "Reasons:"; echo "${requirements.join("\n")}"; exit 1;`;
return command;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
return command;
};
export const getGitlabRepositories = async (gitlabId?: string) => {
if (!gitlabId) {
return [];
}
await refreshGitlabToken(gitlabId);
const gitlabProvider = await findGitlabById(gitlabId);
const allProjects = await validateGitlabProvider(gitlabProvider);
const filteredRepos = allProjects.filter((repo: any) => {
const { full_path, kind } = repo.namespace;
const groupName = gitlabProvider.groupName?.toLowerCase();
if (groupName) {
return groupName
.split(",")
.some((name) =>
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
);
}
return kind === "user";
});
const mappedRepositories = filteredRepos.map((repo: any) => {
return {
id: repo.id,
name: repo.name,
url: repo.path_with_namespace,
owner: {
username: repo.namespace.path,
},
};
});
return mappedRepositories as {
id: number;
name: string;
url: string;
owner: {
username: string;
};
}[];
};
export const getGitlabBranches = async (input: {
id?: number;
gitlabId?: string;
owner: string;
repo: string;
}) => {
if (!input.gitlabId || !input.id || input.id === 0) {
return [];
}
const gitlabProvider = await findGitlabById(input.gitlabId);
const allBranches = [];
let page = 1;
const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) {
const branchesResponse = await fetch(
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
},
},
);
if (!branchesResponse.ok) {
throw new Error(
`Failed to fetch branches: ${branchesResponse.statusText}`,
);
}
const branches = await branchesResponse.json();
if (branches.length === 0) {
break;
}
allBranches.push(...branches);
page++;
// Check if we've reached the total using headers (optional optimization)
const total = branchesResponse.headers.get("x-total");
if (total && allBranches.length >= Number.parseInt(total)) {
break;
}
}
return allBranches as {
id: string;
name: string;
commit: {
id: string;
};
}[];
};
export const testGitlabConnection = async (
input: typeof apiGitlabTestConnection._type,
) => {
const { gitlabId, groupName } = input;
if (!gitlabId) {
throw new Error("Gitlab provider not found");
}
await refreshGitlabToken(gitlabId);
const gitlabProvider = await findGitlabById(gitlabId);
const repositories = await validateGitlabProvider(gitlabProvider);
const filteredRepos = repositories.filter((repo: any) => {
const { full_path, kind } = repo.namespace;
if (groupName) {
return groupName
.split(",")
.some((name) =>
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
);
}
return kind === "user";
});
return filteredRepos.length;
};
export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
try {
const allProjects = [];
let page = 1;
const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) {
const response = await fetch(
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
},
},
);
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to fetch repositories: ${response.statusText}`,
});
}
const projects = await response.json();
if (projects.length === 0) {
break;
}
allProjects.push(...projects);
page++;
const total = response.headers.get("x-total");
if (total && allProjects.length >= Number.parseInt(total)) {
break;
}
}
return allProjects;
} catch (error) {
throw error;
}
};