feat: add clone by gitlab, github and bitbucket

This commit is contained in:
Mauricio Siu
2024-09-01 00:18:45 -06:00
parent a8408a11d9
commit 249fe8c7fe
6 changed files with 368 additions and 78 deletions

View File

@@ -11,11 +11,13 @@ import {
getBitbucketProvider, getBitbucketProvider,
getGitlabProvider, getGitlabProvider,
haveGithubRequirements, haveGithubRequirements,
haveGitlabRequirements,
removeGithubProvider, removeGithubProvider,
updateGitlabProvider,
} from "../services/git-provider"; } from "../services/git-provider";
import { z } from "zod"; import { z } from "zod";
import {
haveGitlabRequirements,
refreshGitlabToken,
} from "@/server/utils/providers/gitlab";
export const gitProvider = createTRPCRouter({ export const gitProvider = createTRPCRouter({
getAll: protectedProcedure.query(async () => { getAll: protectedProcedure.query(async () => {
@@ -164,8 +166,7 @@ export const gitProvider = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
console.log(input); if (!input.gitlabProviderId) {
if (!input.gitlabProviderId || !input.repo || !input.owner) {
return []; return [];
} }
@@ -329,47 +330,6 @@ export const gitProvider = createTRPCRouter({
} }
}), }),
}); });
async function refreshGitlabToken(gitlabProviderId: string) {
const gitlabProvider = await getGitlabProvider(gitlabProviderId);
const currentTime = Math.floor(Date.now() / 1000);
const safetyMargin = 60;
if (
gitlabProvider.expiresAt &&
currentTime + safetyMargin < gitlabProvider.expiresAt
) {
console.log("Token still valid, no need to refresh");
return;
}
const response = await fetch("https://gitlab.com/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 updateGitlabProvider(gitlabProviderId, {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt,
});
return data;
}
// 1725175543 // 1725175543
// { // {
// access_token: '11d422887d8fac712191ee9b09dfdb043a705938cd67a4a39f36b4bc65b3106d', // access_token: '11d422887d8fac712191ee9b09dfdb043a705938cd67a4a39f36b4bc65b3106d',

View File

@@ -21,6 +21,8 @@ import { createDeployment, updateDeploymentStatus } from "./deployment";
import { sendBuildErrorNotifications } from "@/server/utils/notifications/build-error"; import { sendBuildErrorNotifications } from "@/server/utils/notifications/build-error";
import { sendBuildSuccessNotifications } from "@/server/utils/notifications/build-success"; import { sendBuildSuccessNotifications } from "@/server/utils/notifications/build-success";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
import { cloneGitlabRepository } from "@/server/utils/providers/gitlab";
import { cloneBitbucketRepository } from "@/server/utils/providers/bitbucket";
export type Application = typeof applications.$inferSelect; export type Application = typeof applications.$inferSelect;
export const createApplication = async ( export const createApplication = async (
@@ -81,6 +83,9 @@ export const findApplicationById = async (applicationId: string) => {
security: true, security: true,
ports: true, ports: true,
registry: true, registry: true,
gitlabProvider: true,
githubProvider: true,
bitbucketProvider: true,
}, },
}); });
if (!application) { if (!application) {
@@ -150,7 +155,13 @@ export const deployApplication = async ({
try { try {
if (application.sourceType === "github") { if (application.sourceType === "github") {
await cloneGithubRepository(admin, application, deployment.logPath); await cloneGithubRepository(application, deployment.logPath);
await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "gitlab") {
await cloneGitlabRepository(application, deployment.logPath);
await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "bitbucket") {
await cloneBitbucketRepository(application, deployment.logPath);
await buildApplication(application, deployment.logPath); await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "docker") { } else if (application.sourceType === "docker") {
await buildDocker(application, deployment.logPath); await buildDocker(application, deployment.logPath);
@@ -214,6 +225,10 @@ export const rebuildApplication = async ({
try { try {
if (application.sourceType === "github") { if (application.sourceType === "github") {
await buildApplication(application, deployment.logPath); await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "gitlab") {
await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "bitbucket") {
await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "docker") { } else if (application.sourceType === "docker") {
await buildDocker(application, deployment.logPath); await buildDocker(application, deployment.logPath);
} else if (application.sourceType === "git") { } else if (application.sourceType === "git") {

View File

@@ -142,10 +142,6 @@ export const haveGithubRequirements = (githubProvider: GithubProvider) => {
); );
}; };
export const haveGitlabRequirements = (gitlabProvider: GitlabProvider) => {
return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken);
};
export const getGitlabProvider = async (gitlabProviderId: string) => { export const getGitlabProvider = async (gitlabProviderId: string) => {
const gitlabProviderResult = await db.query.gitlabProvider.findFirst({ const gitlabProviderResult = await db.query.gitlabProvider.findFirst({
where: eq(gitlabProvider.gitlabProviderId, gitlabProviderId), where: eq(gitlabProvider.gitlabProviderId, gitlabProviderId),

View File

@@ -0,0 +1,110 @@
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { TRPCError } from "@trpc/server";
import { recreateDirectory } from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync";
import type { InferResultType } from "@/server/types/with";
export type ApplicationWithBitbucket = InferResultType<
"applications",
{ bitbucketProvider: true }
>;
export const cloneBitbucketRepository = async (
entity: ApplicationWithBitbucket,
logPath: string,
isCompose = false,
) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const {
appName,
bitbucketRepository,
bitbucketOwner,
bitbucketBranch,
bitbucketProviderId,
bitbucketProvider,
} = entity;
if (!bitbucketProviderId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Bitbucket Provider not found",
});
}
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
try {
writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
await spawnAsync(
"git",
[
"clone",
"--branch",
bitbucketBranch!,
"--depth",
"1",
cloneUrl,
outputPath,
"--progress",
],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`);
} catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`);
throw error;
} finally {
writeStream.end();
}
};
export const cloneRawBitbucketRepository = async (
entity: ApplicationWithBitbucket,
) => {
const {
appName,
bitbucketRepository,
bitbucketOwner,
bitbucketBranch,
bitbucketProviderId,
bitbucketProvider,
} = entity;
if (!bitbucketProviderId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Bitbucket Provider not found",
});
}
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
try {
await spawnAsync("git", [
"clone",
"--branch",
bitbucketBranch!,
"--depth",
"1",
cloneUrl,
outputPath,
"--progress",
]);
} catch (error) {
throw error;
}
};

View File

@@ -1,15 +1,19 @@
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { type Admin, findAdmin } from "@/server/api/services/admin";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants"; import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { createAppAuth } from "@octokit/auth-app"; import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
import { recreateDirectory } from "../filesystem/directory"; import { recreateDirectory } from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
import type { InferResultType } from "@/server/types/with";
import {
getGithubProvider,
type GithubProvider,
} from "@/server/api/services/git-provider";
export const authGithub = (admin: Admin) => { export const authGithub = (githubProvider: GithubProvider) => {
if (!haveGithubRequirements(admin)) { if (!haveGithubRequirements(githubProvider)) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Github Account not configured correctly", message: "Github Account not configured correctly",
@@ -19,9 +23,9 @@ export const authGithub = (admin: Admin) => {
const octokit = new Octokit({ const octokit = new Octokit({
authStrategy: createAppAuth, authStrategy: createAppAuth,
auth: { auth: {
appId: admin?.githubAppId || 0, appId: githubProvider?.githubAppId || 0,
privateKey: admin?.githubPrivateKey || "", privateKey: githubProvider?.githubPrivateKey || "",
installationId: admin?.githubInstallationId, installationId: githubProvider?.githubInstallationId,
}, },
}); });
@@ -40,11 +44,11 @@ export const getGithubToken = async (
return installation.token; return installation.token;
}; };
export const haveGithubRequirements = (admin: Admin) => { export const haveGithubRequirements = (githubProvider: GithubProvider) => {
return !!( return !!(
admin?.githubAppId && githubProvider?.githubAppId &&
admin?.githubPrivateKey && githubProvider?.githubPrivateKey &&
admin?.githubInstallationId githubProvider?.githubInstallationId
); );
}; };
@@ -63,19 +67,24 @@ const getErrorCloneRequirements = (entity: {
return reasons; return reasons;
}; };
export type ApplicationWithGithub = InferResultType<
"applications",
{ githubProvider: true }
>;
export const cloneGithubRepository = async ( export const cloneGithubRepository = async (
admin: Admin, entity: ApplicationWithGithub,
entity: {
appName: string;
repository?: string | null;
owner?: string | null;
branch?: string | null;
},
logPath: string, logPath: string,
isCompose = false, isCompose = false,
) => { ) => {
const writeStream = createWriteStream(logPath, { flags: "a" }); const writeStream = createWriteStream(logPath, { flags: "a" });
const { appName, repository, owner, branch } = entity; const { appName, repository, owner, branch, githubProviderId } = entity;
if (!githubProviderId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "GitHub Provider not found",
});
}
const requirements = getErrorCloneRequirements(entity); const requirements = getErrorCloneRequirements(entity);
@@ -92,9 +101,11 @@ export const cloneGithubRepository = async (
message: "Error: GitHub repository information is incomplete.", message: "Error: GitHub repository information is incomplete.",
}); });
} }
const githubProvider = await getGithubProvider(githubProviderId);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH; const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code"); const outputPath = join(basePath, appName, "code");
const octokit = authGithub(admin); const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit); const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`; const repoclone = `github.com/${owner}/${repository}.git`;
await recreateDirectory(outputPath); await recreateDirectory(outputPath);
@@ -129,17 +140,21 @@ export const cloneGithubRepository = async (
} }
}; };
export const cloneRawGithubRepository = async (entity: { export const cloneRawGithubRepository = async (
appName: string; entity: ApplicationWithGithub,
repository?: string | null; ) => {
owner?: string | null; const { appName, repository, owner, branch, githubProviderId } = entity;
branch?: string | null;
}) => { if (!githubProviderId) {
const { appName, repository, owner, branch } = entity; throw new TRPCError({
const admin = await findAdmin(); code: "NOT_FOUND",
message: "GitHub Provider not found",
});
}
const githubProvider = await getGithubProvider(githubProviderId);
const basePath = COMPOSE_PATH; const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code"); const outputPath = join(basePath, appName, "code");
const octokit = authGithub(admin); const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit); const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`; const repoclone = `github.com/${owner}/${repository}.git`;
await recreateDirectory(outputPath); await recreateDirectory(outputPath);

View File

@@ -0,0 +1,194 @@
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import { findAdmin } from "@/server/api/services/admin";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { TRPCError } from "@trpc/server";
import { recreateDirectory } from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync";
import {
getGitlabProvider,
type GitlabProvider,
updateGitlabProvider,
} from "@/server/api/services/git-provider";
import type { InferResultType } from "@/server/types/with";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await getGitlabProvider(gitlabProviderId);
const currentTime = Math.floor(Date.now() / 1000);
const safetyMargin = 60;
if (
gitlabProvider.expiresAt &&
currentTime + safetyMargin < gitlabProvider.expiresAt
) {
console.log("Token still valid, no need to refresh");
return;
}
const response = await fetch("https://gitlab.com/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 updateGitlabProvider(gitlabProviderId, {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt,
});
return data;
};
export const haveGitlabRequirements = (gitlabProvider: GitlabProvider) => {
return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken);
};
const getErrorCloneRequirements = (entity: {
repository?: string | null;
owner?: string | null;
branch?: string | null;
}) => {
const reasons: string[] = [];
const { repository, owner, branch } = entity;
if (!repository) reasons.push("1. Repository not assigned.");
if (!owner) reasons.push("2. Owner not specified.");
if (!branch) reasons.push("3. Branch not defined.");
return reasons;
};
export type ApplicationWithGitlab = InferResultType<
"applications",
{ gitlabProvider: true }
>;
export const cloneGitlabRepository = async (
entity: ApplicationWithGitlab,
logPath: string,
isCompose = false,
) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const {
appName,
gitlabRepository,
gitlabOwner,
gitlabBranch,
gitlabProviderId,
gitlabProvider,
} = entity;
if (!gitlabProviderId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Gitlab Provider not found",
});
}
await refreshGitlabToken(gitlabProviderId);
const requirements = getErrorCloneRequirements(entity);
// Check if requirements are met
if (requirements.length > 0) {
writeStream.write(
`\nGitLab Repository configuration failed for application: ${appName}\n`,
);
writeStream.write("Reasons:\n");
writeStream.write(requirements.join("\n"));
writeStream.end();
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error: GitLab repository information is incomplete.",
});
}
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabOwner}/${gitlabRepository}.git`;
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try {
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`);
await spawnAsync(
"git",
[
"clone",
"--branch",
gitlabBranch!,
"--depth",
"1",
cloneUrl,
outputPath,
"--progress",
],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`);
throw error;
} finally {
writeStream.end();
}
};
export const cloneRawGitlabRepository = async (
entity: ApplicationWithGitlab,
) => {
const {
appName,
gitlabRepository,
gitlabOwner,
gitlabBranch,
gitlabProviderId,
gitlabProvider,
} = entity;
if (!gitlabProviderId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Gitlab Provider not found",
});
}
await refreshGitlabToken(gitlabProviderId);
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabOwner}/${gitlabRepository}.git`;
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try {
await spawnAsync("git", [
"clone",
"--branch",
gitlabBranch!,
"--depth",
"1",
cloneUrl,
outputPath,
"--progress",
]);
} catch (error) {
throw error;
}
};