diff --git a/.dockerignore b/.dockerignore
index 3ac30b8..ae6ae08 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,4 +7,5 @@ README.md
!.next/static
!.next/standalone
.git
-db
\ No newline at end of file
+db
+internal
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 46d03f2..c3449ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,4 @@ kube-config.config
kube-config.config_clusteradmin
kube-config.config_old
kube-config.config_restricted
+internal
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
index d0bf040..96c0fbd 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index b6cb98f..c739c72 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.1",
"reflect-metadata": "^0.2.2",
+ "simple-git": "^3.27.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^1.5.0",
diff --git a/shared/git/app_shift_d1fbbc15 b/shared/git/app_shift_d1fbbc15
new file mode 160000
index 0000000..94cb1c7
--- /dev/null
+++ b/shared/git/app_shift_d1fbbc15
@@ -0,0 +1 @@
+Subproject commit 94cb1c77b78187ad702b7f4cda1819cfba2f3987
diff --git a/shared/git/app_test_with_token_19e7cbbc b/shared/git/app_test_with_token_19e7cbbc
new file mode 160000
index 0000000..bc58688
--- /dev/null
+++ b/shared/git/app_test_with_token_19e7cbbc
@@ -0,0 +1 @@
+Subproject commit bc586888a074511c9ad4244f03d1de883c6de3f9
diff --git a/src/app/project/app/[tabName]/action.ts b/src/app/project/app/[tabName]/action.ts
index e98da2a..205b9da 100644
--- a/src/app/project/app/[tabName]/action.ts
+++ b/src/app/project/app/[tabName]/action.ts
@@ -6,10 +6,10 @@ import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
-export const deploy = async (appId: string) =>
+export const deploy = async (appId: string, forceBuild = false) =>
simpleAction(async () => {
await getAuthUserSession();
- await appService.buildAndDeploy(appId);
+ await appService.buildAndDeploy(appId, forceBuild);
return new SuccessActionResult(undefined, 'Successfully started deployment.');
});
diff --git a/src/app/project/app/[tabName]/app-action-buttons.tsx b/src/app/project/app/[tabName]/app-action-buttons.tsx
index a9dc582..ed72949 100644
--- a/src/app/project/app/[tabName]/app-action-buttons.tsx
+++ b/src/app/project/app/[tabName]/app-action-buttons.tsx
@@ -6,6 +6,7 @@ import { deploy, startApp, stopApp } from "./action";
import { AppExtendedModel } from "@/model/app-extended.model";
import { Toast } from "@/lib/toast.utils";
import AppStatus from "./app-status";
+import { Hammer, Pause, Play, Rocket } from "lucide-react";
export default function AppActionButtons({
app
@@ -15,10 +16,10 @@ export default function AppActionButtons({
return
-
-
-
-
+
+
+
+
;
}
\ No newline at end of file
diff --git a/src/app/project/app/[tabName]/overview/deployments.tsx b/src/app/project/app/[tabName]/overview/deployments.tsx
index bdcbc08..f4df8d6 100644
--- a/src/app/project/app/[tabName]/overview/deployments.tsx
+++ b/src/app/project/app/[tabName]/overview/deployments.tsx
@@ -83,8 +83,10 @@ export default function BuildsTab({
{item.status}],
["startTime", "Started At", true, (item) => formatDateTime(item.createdAt)],
+ ['gitCommit', 'Git Commit', true],
]}
data={appBuilds}
hideSearchBar={true}
diff --git a/src/model/build-job.ts b/src/model/build-job.ts
index 544ad66..8be8330 100644
--- a/src/model/build-job.ts
+++ b/src/model/build-job.ts
@@ -1,3 +1,4 @@
+import { GitCommit } from "lucide-react";
import { z } from "zod";
export const buildJobStatusEnumZod = z.union([z.literal('UNKNOWN'), z.literal('RUNNING'), z.literal('FAILED'), z.literal('SUCCEEDED')]);
@@ -5,7 +6,8 @@ export const buildJobStatusEnumZod = z.union([z.literal('UNKNOWN'), z.literal('R
export const buildJobSchemaZod = z.object({
name: z.string(),
startTime: z.date(),
- status: buildJobStatusEnumZod
+ status: buildJobStatusEnumZod,
+ gitCommit: z.string(),
});
export type BuildJobModel = z.infer;
diff --git a/src/model/deployment-info.model.ts b/src/model/deployment-info.model.ts
index 357a6b8..784504a 100644
--- a/src/model/deployment-info.model.ts
+++ b/src/model/deployment-info.model.ts
@@ -14,7 +14,8 @@ export const deploymentInfoZodModel = z.object({
replicasetName: z.string().optional(),
buildJobName: z.string().optional(),
createdAt: z.date(),
- status: deploymentStatusEnumZod
+ status: deploymentStatusEnumZod,
+ gitCommit: z.string().optional(),
});
export type DeploymentInfoModel = z.infer;
diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts
index 09c6bcd..cc90726 100644
--- a/src/server/services/app.service.ts
+++ b/src/server/services/app.service.ts
@@ -15,15 +15,14 @@ import svcService from "./svc.service";
class AppService {
- async buildAndDeploy(appId: string) {
+ async buildAndDeploy(appId: string, forceBuild: boolean = false) {
const app = await this.getExtendedById(appId);
if (app.sourceType === 'GIT') {
// first make build
- await namespaceService.createNamespaceIfNotExists(BUILD_NAMESPACE);
- const [buildJobName, buildPromise] = await buildService.buildApp(app);
+ const [buildJobName, gitCommitHash, buildPromise] = await buildService.buildApp(app, forceBuild);
buildPromise.then(async () => {
console.warn('Build job finished, deploying...');
- await deploymentService.createDeployment(app, buildJobName);
+ await deploymentService.createDeployment(app, buildJobName, gitCommitHash);
});
} else {
// only deploy
diff --git a/src/server/services/build.service.ts b/src/server/services/build.service.ts
index 0f1cda3..c9d402b 100644
--- a/src/server/services/build.service.ts
+++ b/src/server/services/build.service.ts
@@ -7,6 +7,9 @@ import { ServiceException } from "@/model/service.exception.model";
import { PodsInfoModel } from "@/model/pods-info.model";
import namespaceService from "./namespace.service";
import { Constants } from "../utils/constants";
+import gitService from "./git.service";
+import deploymentService from "./deployment.service";
+import deploymentLogService from "./deployment-logs.service";
const kanikoImage = "gcr.io/kaniko-project/executor:latest";
const REGISTRY_NODE_PORT = 30100;
@@ -20,13 +23,28 @@ export const REGISTRY_URL_INTERNAL = `${REGISTRY_SVC_NAME}.${BUILD_NAMESPACE}.sv
class BuildService {
- async buildApp(app: AppExtendedModel): Promise<[string, Promise]> {
+ async buildApp(app: AppExtendedModel, forceBuild: boolean = false): Promise<[string, string, Promise]> {
+ await namespaceService.createNamespaceIfNotExists(BUILD_NAMESPACE);
await this.deployRegistryIfNotExists();
- const runningJobsForApp = await this.getBuildsForApp(app.id);
- if (runningJobsForApp.some((job) => job.status === 'RUNNING')) {
+ const buildsForApp = await this.getBuildsForApp(app.id);
+ if (buildsForApp.some((job) => job.status === 'RUNNING')) {
throw new ServiceException("A build job is already running for this app.");
}
+ // Check if last build is already up to date with data in git repo
+ const latestSuccessfulBuld = buildsForApp.find(x => x.status === 'SUCCEEDED');
+ const latestRemoteGitHash = await gitService.getLatestRemoteCommitHash(app);
+ if (!forceBuild && latestSuccessfulBuld?.gitCommit && latestRemoteGitHash &&
+ latestSuccessfulBuld?.gitCommit === latestRemoteGitHash) {
+ console.log(`Last build is already up to date with data in git repo for app ${app.id}`);
+ // todo check if the container is still in registry
+ return [latestSuccessfulBuld.name, latestRemoteGitHash, Promise.resolve()];
+ }
+ return await this.createAndStartBuildJob(app, latestRemoteGitHash);
+ }
+
+ private async createAndStartBuildJob(app: AppExtendedModel, latestRemoteGitHash: string): Promise<[string, string, Promise]> {
+
const buildName = StringUtils.addRandomSuffix(StringUtils.toJobName(app.id));
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
@@ -37,6 +55,7 @@ class BuildService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ [Constants.QS_ANNOTATION_GIT_COMMIT]: latestRemoteGitHash,
}
},
spec: {
@@ -51,7 +70,7 @@ class BuildService {
`--dockerfile=${app.dockerfilePath}`,
`--insecure`,
`--log-format=text`,
- `--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`,
+ `--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`, // todo change to shared folder
`--destination=${this.createInternalContainerRegistryUrlForAppId(app.id)}`
]
},
@@ -79,7 +98,7 @@ class BuildService {
const buildJobPromise = this.waitForJobCompletion(jobDefinition.metadata!.name!)
- return [buildName, buildJobPromise];
+ return [buildName, latestRemoteGitHash, buildJobPromise];
}
createInternalContainerRegistryUrlForAppId(appId?: string) {
@@ -128,6 +147,7 @@ class BuildService {
name: job.metadata?.name,
startTime: job.status?.startTime,
status: this.getJobStatusString(job.status),
+ gitCommit: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT],
} as BuildJobModel;
});
builds.sort((a, b) => {
diff --git a/src/server/services/deployment-logs.service.ts b/src/server/services/deployment-logs.service.ts
new file mode 100644
index 0000000..48ebdb4
--- /dev/null
+++ b/src/server/services/deployment-logs.service.ts
@@ -0,0 +1,82 @@
+import fsPromises from 'fs/promises';
+import fs from 'fs';
+import { PathUtils } from '../utils/path.utils';
+import { FsUtils } from '../utils/fs.utils';
+
+class DeploymentLogService {
+
+ async writeLogs(deploymentId: string, logs: string) {
+ try {
+ await FsUtils.createDirIfNotExistsAsync(PathUtils.deploymentLogsPath, true);
+ const logFilePath = PathUtils.appDeploymentLogFile(deploymentId);
+ await fsPromises.appendFile(logFilePath, logs, {
+ encoding: 'utf-8'
+ });
+ } catch (ex) {
+ console.error(`Error writing logs for deployment ${deploymentId}: ${ex}`);
+ }
+ }
+
+ catchErrosAndLog(appId: string, deploymentId: string, fn: (logFunc: (logData: string) => void) => TReturnType): TReturnType {
+ try {
+ return fn((logData: string) => {
+ this.writeLogs(deploymentId, logData);
+ });
+ } catch (ex) {
+ this.writeLogs(deploymentId, `[Error]: ${(ex as any)?.message}`);
+ throw ex;
+ }
+ }
+
+
+ async getLogsStream(appId: string, deploymentId: string, streamedData: (data: string) => void) {
+ await FsUtils.createDirIfNotExistsAsync(PathUtils.deploymentLogsPath, true);
+ const logFilePath = PathUtils.appDeploymentLogFile(deploymentId);
+
+ if (!await FsUtils.fileExists(logFilePath)) {
+ return undefined;
+ }
+ // Create a read stream
+ let fileStream = fs.createReadStream(logFilePath, {
+ encoding: 'utf8',
+ start: 0,
+ flags: 'r'
+ });
+
+ // Watch for changes in the file and read new lines when the file is updated
+ const watcher = fs.watch(logFilePath, (eventType) => {
+ if (eventType === 'change') {
+ // Create a new read stream starting from the current end of the file
+ const newStream = fs.createReadStream(logFilePath, {
+ encoding: 'utf8',
+ start: fileStream.bytesRead,
+ flags: 'r'
+ });
+
+ newStream.on('data', (chunk: string) => {
+ streamedData(chunk);
+ });
+
+ // Update the read stream pointer
+ newStream.on('end', () => {
+ fileStream.bytesRead += newStream.readableLength; // Buffer.byteLength(); // todo check if this works
+ newStream.close();
+ });
+ }
+ });
+
+ return () => {
+ watcher.close();
+ fileStream.close();
+ }
+ }
+
+}
+
+const deploymentLogService = new DeploymentLogService();
+export default deploymentLogService;
+
+
+export const dlog = (deploymentId: string, data: string) => {
+ deploymentLogService.writeLogs(deploymentId, data);
+}
diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts
index 0389cb6..79a1d89 100644
--- a/src/server/services/deployment.service.ts
+++ b/src/server/services/deployment.service.ts
@@ -40,7 +40,7 @@ class DeploymentService {
}
}
- async createDeployment(app: AppExtendedModel, buildJobName?: string) {
+ async createDeployment(app: AppExtendedModel, buildJobName?: string, gitCommitHash?: string) {
await this.validateDeployment(app);
await namespaceService.createNamespaceIfNotExists(app.projectId);
const appHasPvcChanges = await pvcService.doesAppConfigurationIncreaseAnyPvcSize(app)
@@ -95,6 +95,10 @@ class DeploymentService {
body.spec!.template!.metadata!.annotations!.buildJobName = buildJobName; // add buildJobName to deployment
}
+ if (gitCommitHash) {
+ body.spec!.template!.metadata!.annotations![Constants.QS_ANNOTATION_GIT_COMMIT] = gitCommitHash; // add gitCommitHash to deployment
+ }
+
if (!appHasPvcChanges && app.appVolumes.length === 0 || app.appVolumes.every(vol => vol.accessMode === 'ReadWriteMany')) {
body.spec!.strategy = {
type: 'RollingUpdate',
@@ -163,7 +167,8 @@ class DeploymentService {
replicasetName: undefined,
createdAt: build.startTime!,
buildJobName: build.name!,
- status: this.mapBuildStatusToDeploymentStatus(build.status)
+ status: this.mapBuildStatusToDeploymentStatus(build.status),
+ gitCommit: build.gitCommit,
}
});
replicasetRevisions.push(...runningOrFailedBuilds);
@@ -196,6 +201,7 @@ class DeploymentService {
replicasetName: rs.metadata?.name!,
createdAt: rs.metadata?.creationTimestamp!,
buildJobName: rs.spec?.template?.metadata?.annotations?.buildJobName!,
+ gitCommit: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT],
status: status
}
});
diff --git a/src/server/services/git.service.ts b/src/server/services/git.service.ts
new file mode 100644
index 0000000..97421b6
--- /dev/null
+++ b/src/server/services/git.service.ts
@@ -0,0 +1,88 @@
+import { ServiceException } from "@/model/service.exception.model";
+import { AppExtendedModel } from "@/model/app-extended.model";
+import simpleGit from "simple-git";
+import { PathUtils } from "../utils/path.utils";
+import { FsUtils } from "../utils/fs.utils";
+
+class GitService {
+
+ async getLatestRemoteCommitHash(app: AppExtendedModel) {
+ try {
+ const git = await this.pullLatestChangesFromRepo(app);
+
+ // Get the latest commit hash on the default branch (e.g., 'origin/main')
+ const log = await git.log(['origin/' + app.gitBranch]); // Replace 'main' with your branch name if needed
+
+ if (log.latest) {
+ return log.latest.hash;
+ } else {
+ throw new ServiceException("The git repository is empty.");
+ }
+ } catch (error) {
+ console.error('Error while connecting to the git repository:', error);
+ throw new ServiceException("Error while connecting to the git repository.");
+ } finally {
+ await this.cleanupLocalGitDataForApp(app);
+ }
+ }
+
+ async cleanupLocalGitDataForApp(app: AppExtendedModel) {
+ const gitPath = PathUtils.gitRootPathForApp(app.id);
+ await FsUtils.deleteDirIfExistsAsync(gitPath, true);
+ }
+
+ async pullLatestChangesFromRepo(app: AppExtendedModel) {
+ console.log(`Pulling latest source for app ${app.id}...`);
+ const gitPath = PathUtils.gitRootPathForApp(app.id);
+
+ await FsUtils.deleteDirIfExistsAsync(gitPath, true);
+ await FsUtils.createDirIfNotExistsAsync(gitPath, true);
+
+ const git = simpleGit(gitPath);
+ const gitUrl = this.getGitUrl(app);
+
+ // initial clone
+ console.log(await git.clone(gitUrl, gitPath));
+ console.log(await git.checkout(app.gitBranch ?? 'main'));
+ console.log(`Source for app ${app.id} has been cloned successfully.`);
+
+ return git;
+ }
+
+
+ async checkIfLocalRepoIsUpToDate(app: AppExtendedModel) {
+ const gitPath = PathUtils.gitRootPathForApp(app.id);
+ if (!FsUtils.directoryExists(gitPath)) {
+ return false;
+ }
+
+ if (await FsUtils.isFolderEmpty(gitPath)) {
+ return false;
+ }
+
+ const git = simpleGit(gitPath);
+ await git.fetch();
+
+ const status = await git.status();
+ if (status.behind > 0) {
+ console.log(`The local repository is behind by ${status.behind} commits and needs to be updated.`);
+ return false;
+ } else if (status.ahead > 0) {
+ throw new Error(`The local repository is ahead by ${status.ahead} commits. This should not happen.`);
+ }
+
+ // The local repository is up to date
+ return true
+ }
+
+
+ private getGitUrl(app: AppExtendedModel) {
+ if (app.gitUsername && app.gitToken) {
+ return app.gitUrl!.replace('https://', `https://${app.gitUsername}:${app.gitToken}@`);
+ }
+ return app.gitUrl!;
+ }
+}
+
+const gitService = new GitService();
+export default gitService;
diff --git a/src/server/services/pvc.service.ts b/src/server/services/pvc.service.ts
index 2d0d360..cb4e5cf 100644
--- a/src/server/services/pvc.service.ts
+++ b/src/server/services/pvc.service.ts
@@ -9,6 +9,8 @@ import { MemoryCalcUtils } from "../utils/memory-caluclation.utils";
class PvcService {
+ static readonly SHARED_PVC_NAME = 'qs-shared-pvc';
+
async doesAppConfigurationIncreaseAnyPvcSize(app: AppExtendedModel) {
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
diff --git a/src/server/utils/constants.ts b/src/server/utils/constants.ts
index cc3f8dc..8b873f2 100644
--- a/src/server/utils/constants.ts
+++ b/src/server/utils/constants.ts
@@ -1,4 +1,6 @@
export class Constants {
static readonly QS_ANNOTATION_APP_ID = 'qs-app-id';
static readonly QS_ANNOTATION_PROJECT_ID = 'qs-project-id';
+ static readonly QS_ANNOTATION_DEPLOYMENT_ID = 'qs-deplyoment-id';
+ static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit';
}
\ No newline at end of file
diff --git a/src/server/utils/fs.utils.ts b/src/server/utils/fs.utils.ts
new file mode 100644
index 0000000..9232143
--- /dev/null
+++ b/src/server/utils/fs.utils.ts
@@ -0,0 +1,67 @@
+import fs from "fs"
+import fsPromises from "fs/promises"
+
+export class FsUtils {
+
+ static async fileExists(pathName: string) {
+ try {
+ await fsPromises.access(pathName, fs.constants.F_OK);
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ }
+
+ static directoryExists(pathName: string) {
+ try {
+ return fs.existsSync(pathName);
+ } catch (ex) {
+ return false;
+ }
+ }
+
+ static async isFolderEmpty(pathName: string) {
+ try {
+ const files = await fsPromises.readdir(pathName);
+ return files.length === 0;
+ } catch (ex) {
+ return true;
+ }
+ }
+
+ static createDirIfNotExists(pathName: string, recursive = false) {
+ if (!this.directoryExists(pathName)) {
+ fs.mkdirSync(pathName, {
+ recursive
+ });
+ }
+ }
+
+ static async createDirIfNotExistsAsync(pathName: string, recursive = false) {
+ let exists = false;
+ try {
+ exists = fs.existsSync(pathName);
+ } catch (ex) {
+
+ }
+ if (!exists) {
+ await fsPromises.mkdir(pathName, {
+ recursive
+ });
+ }
+ }
+ static async deleteDirIfExistsAsync(pathName: string, recursive = false) {
+ let exists = false;
+ try {
+ exists = fs.existsSync(pathName);
+ } catch (ex) {
+
+ }
+ if (!exists) {
+ return;
+ }
+ await fsPromises.rm(pathName, {
+ recursive
+ });
+ }
+}
diff --git a/src/server/utils/path.utils.ts b/src/server/utils/path.utils.ts
new file mode 100644
index 0000000..cc9026c
--- /dev/null
+++ b/src/server/utils/path.utils.ts
@@ -0,0 +1,26 @@
+import path from 'path';
+
+export class PathUtils {
+
+ static internalDataRoot = process.env.NODE_ENV === 'production' ? '/mnt/internal' : '/workspace/internal';
+
+ static get gitRootPath() {
+ return path.join(this.internalDataRoot, 'git');
+ }
+
+ static get deploymentLogsPath() {
+ return path.join(this.internalDataRoot, 'deployment-logs');
+ }
+
+ static appDeploymentLogFile(deploymentId: string): string {
+ return path.join(this.deploymentLogsPath, `${deploymentId}.log`);
+ }
+
+ static gitRootPathForApp(appId: string): string {
+ return path.join(PathUtils.gitRootPath, this.convertIdToFolderFreindlyName(appId));
+ }
+
+ private static convertIdToFolderFreindlyName(id: string): string {
+ return id.replace(/-/g, '_');
+ }
+}