From 0b526176228d9afb9d7188bc0db86cd59a577eca Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 25 Apr 2026 14:17:36 +0000 Subject: [PATCH 1/9] feat: moved logic of pulling git repository in separate init container for all build methods --- .../build-git-init-container.service.ts | 64 +++++++++++++++++++ ...ld-git-init-container.service.unit.spec.ts | 39 +++++++++++ .../build-init-container.service.ts | 4 +- .../build-workspace.constants.ts | 4 ++ .../dockerfile-build-job-builder.service.ts | 37 ++++++++--- ...ile-build-job-builder.service.unit.spec.ts | 26 ++++++-- .../railpack-build-job-builder.service.ts | 54 ++++++---------- ...ack-build-job-builder.service.unit.spec.ts | 7 +- src/server/services/build.service.ts | 4 +- 9 files changed, 184 insertions(+), 55 deletions(-) create mode 100644 src/server/services/build-job-builders/build-git-init-container.service.ts create mode 100644 src/server/services/build-job-builders/build-git-init-container.service.unit.spec.ts create mode 100644 src/server/services/build-job-builders/build-workspace.constants.ts diff --git a/src/server/services/build-job-builders/build-git-init-container.service.ts b/src/server/services/build-job-builders/build-git-init-container.service.ts new file mode 100644 index 00000000..735fbffe --- /dev/null +++ b/src/server/services/build-job-builders/build-git-init-container.service.ts @@ -0,0 +1,64 @@ +import { V1Container } from "@kubernetes/client-node"; +import { BuildJobBuilderContext } from "./build-job-builder.interface"; +import { BUILD_SOURCE_PATH, BUILD_WORKSPACE_MOUNT_PATH, BUILD_WORKSPACE_VOLUME_NAME } from "./build-workspace.constants"; + +export const BUILD_GIT_INIT_CONTAINER_NAME = 'build-git-init'; + +class BuildGitInitContainerService { + + getInitContainer(ctx: BuildJobBuilderContext): V1Container { + const script = [ + 'set -eu', + 'rm -rf "$SOURCE_PATH"', + 'mkdir -p "$WORKSPACE_PATH"', + 'git clone --depth 1 --single-branch --branch "$GIT_BRANCH" "$GIT_URL" "$SOURCE_PATH"', + 'cd "$SOURCE_PATH"', + 'if ! git cat-file -e "$GIT_COMMIT^{commit}" 2>/dev/null; then', + ' echo "Commit $GIT_COMMIT not found in shallow clone. Fetching commit directly."', + ' git fetch --depth 1 origin "$GIT_COMMIT"', + 'fi', + 'git checkout --detach "$GIT_COMMIT"', + 'echo "Successfully checked out git commit: $(git rev-parse HEAD)"', + ].join('\n'); + + return { + name: BUILD_GIT_INIT_CONTAINER_NAME, + image: 'alpine/git', + command: ['sh', '-c'], + args: [script], + env: [ + { + name: 'GIT_URL', + value: this.getAuthenticatedGitUrl(ctx), + }, + { + name: 'GIT_BRANCH', + value: ctx.app.gitBranch ?? 'main', + }, + { + name: 'GIT_COMMIT', + value: ctx.latestRemoteGitHash, + }, + { + name: 'WORKSPACE_PATH', + value: BUILD_WORKSPACE_MOUNT_PATH, + }, + { + name: 'SOURCE_PATH', + value: BUILD_SOURCE_PATH, + }, + ], + volumeMounts: [{ name: BUILD_WORKSPACE_VOLUME_NAME, mountPath: BUILD_WORKSPACE_MOUNT_PATH }], + }; + } + + private getAuthenticatedGitUrl(ctx: BuildJobBuilderContext) { + if (ctx.app.gitUsername && ctx.app.gitToken) { + return ctx.app.gitUrl!.replace('https://', `https://${ctx.app.gitUsername}:${ctx.app.gitToken}@`); + } + return ctx.app.gitUrl!; + } +} + +const buildGitInitContainerService = new BuildGitInitContainerService(); +export default buildGitInitContainerService; diff --git a/src/server/services/build-job-builders/build-git-init-container.service.unit.spec.ts b/src/server/services/build-job-builders/build-git-init-container.service.unit.spec.ts new file mode 100644 index 00000000..e215b917 --- /dev/null +++ b/src/server/services/build-job-builders/build-git-init-container.service.unit.spec.ts @@ -0,0 +1,39 @@ +import buildGitInitContainerService from "./build-git-init-container.service"; + +describe('BuildGitInitContainerService', () => { + it('builds a git init container that clones into the shared workspace and pins the commit', () => { + const container = buildGitInitContainerService.getInitContainer({ + app: { + id: 'app-1', + projectId: 'project-1', + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + gitUsername: 'user', + gitToken: 'token', + } as any, + buildName: 'build-1', + deploymentId: 'deployment-1', + latestRemoteGitHash: 'abc123', + latestRemoteGitCommitMessage: 'feat: test', + queuedAt: '123', + }); + + expect(container.name).toBe('build-git-init'); + expect(container.image).toBe('alpine/git'); + expect(container.volumeMounts).toEqual([ + { name: 'build-workspace', mountPath: '/workspace' }, + ]); + expect(container.env).toEqual(expect.arrayContaining([ + { name: 'GIT_URL', value: 'https://user:token@github.com/example/repo.git' }, + { name: 'GIT_BRANCH', value: 'main' }, + { name: 'GIT_COMMIT', value: 'abc123' }, + { name: 'SOURCE_PATH', value: '/workspace/source' }, + ])); + + const script = container.args?.[0] ?? ''; + expect(script).toContain('git clone --depth 1 --single-branch --branch "$GIT_BRANCH" "$GIT_URL" "$SOURCE_PATH"'); + expect(script).toContain('git cat-file -e "$GIT_COMMIT^{commit}"'); + expect(script).toContain('git fetch --depth 1 origin "$GIT_COMMIT"'); + expect(script).toContain('git checkout --detach "$GIT_COMMIT"'); + }); +}); diff --git a/src/server/services/build-job-builders/build-init-container.service.ts b/src/server/services/build-job-builders/build-init-container.service.ts index 00768e33..ddc0dff1 100644 --- a/src/server/services/build-job-builders/build-init-container.service.ts +++ b/src/server/services/build-job-builders/build-init-container.service.ts @@ -105,5 +105,5 @@ class BuildInitContainerService { } } -const buildInitContainerService = new BuildInitContainerService(); -export default buildInitContainerService; +const buildQueueInitContainer = new BuildInitContainerService(); +export default buildQueueInitContainer; diff --git a/src/server/services/build-job-builders/build-workspace.constants.ts b/src/server/services/build-job-builders/build-workspace.constants.ts new file mode 100644 index 00000000..02ce65f3 --- /dev/null +++ b/src/server/services/build-job-builders/build-workspace.constants.ts @@ -0,0 +1,4 @@ +export const BUILD_WORKSPACE_VOLUME_NAME = 'build-workspace'; +export const BUILD_WORKSPACE_MOUNT_PATH = '/workspace'; +export const BUILD_SOURCE_PATH = `${BUILD_WORKSPACE_MOUNT_PATH}/source`; +export const RAILPACK_PLAN_PATH = `${BUILD_WORKSPACE_MOUNT_PATH}/plan`; diff --git a/src/server/services/build-job-builders/dockerfile-build-job-builder.service.ts b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.ts index 6a85fea8..a7d5bc50 100644 --- a/src/server/services/build-job-builders/dockerfile-build-job-builder.service.ts +++ b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.ts @@ -2,9 +2,11 @@ import { V1Job } from "@kubernetes/client-node"; import { BuildJobBuilder, BuildJobBuilderContext } from "./build-job-builder.interface"; import { AppBuildMethod } from "@/shared/model/app-source-info.model"; import { Constants } from "@/shared/utils/constants"; -import buildInitContainerService from "./build-init-container.service"; +import buildQueueInitContainer from "./build-init-container.service"; +import buildGitInitContainerService from "./build-git-init-container.service"; import registryService, { BUILD_NAMESPACE } from "../registry.service"; import { PathUtils } from "@/server/utils/path.utils"; +import { BUILD_SOURCE_PATH, BUILD_WORKSPACE_MOUNT_PATH, BUILD_WORKSPACE_VOLUME_NAME } from "./build-workspace.constants"; const buildkitImage = "moby/buildkit:master"; @@ -13,21 +15,18 @@ class DockerfileBuildJobBuilder implements BuildJobBuilder { async buildJobDefinition(ctx: BuildJobBuilderContext): Promise { const contextPaths = PathUtils.splitPath(ctx.app.dockerfilePath || './Dockerfile'); - - let gitContextUrl = `${ctx.app.gitUrl!}#refs/heads/${ctx.app.gitBranch}${contextPaths.folderPath ? ':' + contextPaths.folderPath : ''}`; - if (ctx.app.gitUsername && ctx.app.gitToken) { - const authenticatedGitUrl = ctx.app.gitUrl!.replace('https://', `https://${ctx.app.gitUsername}:${ctx.app.gitToken}@`); - gitContextUrl = `${authenticatedGitUrl}#refs/heads/${ctx.app.gitBranch}${contextPaths.folderPath ? ':' + contextPaths.folderPath : ''}`; - } + const dockerfileContextPath = this.getDockerfileContextPath(contextPaths.folderPath); const buildkitArgs = [ "build", "--frontend", "dockerfile.v0", + "--local", + `context=${dockerfileContextPath}`, + "--local", + `dockerfile=${dockerfileContextPath}`, "--opt", `filename=${contextPaths.filePath}`, - "--opt", - `context=${gitContextUrl}`, "--output", `type=image,name=${registryService.createInternalContainerRegistryUrlForAppId(ctx.app.id)},push=true,registry.insecure=true` ]; @@ -64,7 +63,10 @@ class DockerfileBuildJobBuilder implements BuildJobBuilder { spec: { hostUsers: false, serviceAccountName: 'qs-build-watcher', - initContainers: [buildInitContainerService.getInitContainer(ctx.buildName, ctx.queuedAt)], + initContainers: [ + buildQueueInitContainer.getInitContainer(ctx.buildName, ctx.queuedAt), + buildGitInitContainerService.getInitContainer(ctx), + ], ...(ctx.nodeSelector ? { nodeSelector: ctx.nodeSelector } : {}), containers: [ { @@ -76,15 +78,30 @@ class DockerfileBuildJobBuilder implements BuildJobBuilder { privileged: true }, ...(ctx.resources ? { resources: ctx.resources } : {}), + volumeMounts: [{ name: BUILD_WORKSPACE_VOLUME_NAME, mountPath: BUILD_WORKSPACE_MOUNT_PATH }], }, ], restartPolicy: "Never", + volumes: [ + { + name: BUILD_WORKSPACE_VOLUME_NAME, + emptyDir: {}, + }, + ], }, }, backoffLimit: 0, }, }; } + + private getDockerfileContextPath(folderPath: string | undefined) { + if (!folderPath) { + return BUILD_SOURCE_PATH; + } + + return `${BUILD_SOURCE_PATH}/${folderPath.replace(/^\.\//, '')}`; + } } const dockerfileBuildJobBuilder = new DockerfileBuildJobBuilder(); diff --git a/src/server/services/build-job-builders/dockerfile-build-job-builder.service.unit.spec.ts b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.unit.spec.ts index 3a676863..ec83212d 100644 --- a/src/server/services/build-job-builders/dockerfile-build-job-builder.service.unit.spec.ts +++ b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.unit.spec.ts @@ -21,12 +21,30 @@ describe('DockerfileBuildJobBuilder', () => { expect(job.metadata?.annotations?.['qs-build-method']).toBe('DOCKERFILE'); expect(job.spec?.template?.metadata?.annotations?.['qs-deplyoment-id']).toBe('deployment-1'); - expect(job.spec?.template?.spec?.initContainers).toHaveLength(1); - expect(job.spec?.template?.spec?.containers[0].command).toEqual(['buildctl-daemonless.sh']); - expect(job.spec?.template?.spec?.containers[0].args).toEqual(expect.arrayContaining([ + expect(job.spec?.template?.spec?.initContainers?.map((container) => container.name)).toEqual([ + 'build-queue-init', + 'build-git-init', + ]); + expect(job.spec?.template?.spec?.volumes).toEqual([ + expect.objectContaining({ + name: 'build-workspace', + emptyDir: {}, + }), + ]); + + const buildContainer = job.spec?.template?.spec?.containers[0]!; + + expect(buildContainer.command).toEqual(['buildctl-daemonless.sh']); + expect(buildContainer.volumeMounts).toEqual([ + { name: 'build-workspace', mountPath: '/workspace' }, + ]); + expect(buildContainer.args).toEqual(expect.arrayContaining([ 'dockerfile.v0', + '--local', 'filename=Dockerfile', - 'context=https://github.com/example/repo.git#refs/heads/main:./apps/web', + 'context=/workspace/source/apps/web', + 'dockerfile=/workspace/source/apps/web', ])); + expect(buildContainer.args).not.toContain('context=https://github.com/example/repo.git#refs/heads/main:./apps/web'); }); }); diff --git a/src/server/services/build-job-builders/railpack-build-job-builder.service.ts b/src/server/services/build-job-builders/railpack-build-job-builder.service.ts index a071ec43..6034c72d 100644 --- a/src/server/services/build-job-builders/railpack-build-job-builder.service.ts +++ b/src/server/services/build-job-builders/railpack-build-job-builder.service.ts @@ -2,19 +2,17 @@ import { V1Container, V1Job } from "@kubernetes/client-node"; import { BuildJobBuilder, BuildJobBuilderContext } from "./build-job-builder.interface"; import { AppBuildMethod } from "@/shared/model/app-source-info.model"; import { Constants } from "@/shared/utils/constants"; -import buildInitContainerService from "./build-init-container.service"; +import buildQueueInitContainer from "./build-init-container.service"; +import buildGitInitContainerService from "./build-git-init-container.service"; import registryService, { BUILD_NAMESPACE } from "../registry.service"; +import { BUILD_SOURCE_PATH, BUILD_WORKSPACE_MOUNT_PATH, BUILD_WORKSPACE_VOLUME_NAME, RAILPACK_PLAN_PATH } from "./build-workspace.constants"; const buildkitImage = "moby/buildkit:master"; const railpackVersion = "0.15.1"; export const RAILPACK_FRONTEND_IMAGE = `ghcr.io/railwayapp/railpack-frontend:v${railpackVersion}`; -const sharedVolumeName = 'railpack-workspace'; -const sharedMountPath = '/workspace'; -const sourcePath = `${sharedMountPath}/source`; -const planPath = `${sharedMountPath}/plan`; -const railpackPlanFile = `${planPath}/railpack-plan.json`; -const railpackInfoFile = `${planPath}/railpack-info.json`; +const railpackPlanFile = `${RAILPACK_PLAN_PATH}/railpack-plan.json`; +const railpackInfoFile = `${RAILPACK_PLAN_PATH}/railpack-info.json`; class RailpackBuildJobBuilder implements BuildJobBuilder { @@ -24,9 +22,9 @@ class RailpackBuildJobBuilder implements BuildJobBuilder { const buildkitArgs = [ "build", "--local", - `context=${sourcePath}`, + `context=${BUILD_SOURCE_PATH}`, "--local", - `dockerfile=${planPath}`, + `dockerfile=${RAILPACK_PLAN_PATH}`, "--frontend", "gateway.v0", "--opt", @@ -68,8 +66,9 @@ class RailpackBuildJobBuilder implements BuildJobBuilder { hostUsers: false, serviceAccountName: 'qs-build-watcher', initContainers: [ - buildInitContainerService.getInitContainer(ctx.buildName, ctx.queuedAt), - this.getPreparedRailpackInitContainer(ctx), + buildQueueInitContainer.getInitContainer(ctx.buildName, ctx.queuedAt), + buildGitInitContainerService.getInitContainer(ctx), + this.getPreparedRailpackInitContainer(), ], ...(ctx.nodeSelector ? { nodeSelector: ctx.nodeSelector } : {}), containers: [ @@ -82,13 +81,13 @@ class RailpackBuildJobBuilder implements BuildJobBuilder { privileged: true }, ...(ctx.resources ? { resources: ctx.resources } : {}), - volumeMounts: [{ name: sharedVolumeName, mountPath: sharedMountPath }], + volumeMounts: [{ name: BUILD_WORKSPACE_VOLUME_NAME, mountPath: BUILD_WORKSPACE_MOUNT_PATH }], }, ], restartPolicy: "Never", volumes: [ { - name: sharedVolumeName, + name: BUILD_WORKSPACE_VOLUME_NAME, emptyDir: {}, }, ], @@ -99,17 +98,15 @@ class RailpackBuildJobBuilder implements BuildJobBuilder { }; } - private getPreparedRailpackInitContainer(ctx: BuildJobBuilderContext): V1Container { - const gitUrl = this.getAuthenticatedGitUrl(ctx); + private getPreparedRailpackInitContainer(): V1Container { const script = [ 'set -euo pipefail', - 'apt-get update', - 'apt-get install -y --no-install-recommends ca-certificates curl git', + 'apt-get update -qq', + 'apt-get install -y -qq --no-install-recommends ca-certificates curl', 'rm -rf /var/lib/apt/lists/*', 'curl -fsSL https://railpack.com/install.sh | RAILPACK_VERSION="$RAILPACK_VERSION" sh -s -- --bin-dir /usr/local/bin', - `mkdir -p ${sourcePath} ${planPath}`, - `git clone --depth 1 --single-branch --branch "$GIT_BRANCH" "$GIT_URL" ${sourcePath}`, - `railpack prepare ${sourcePath} --plan-out ${railpackPlanFile} --info-out ${railpackInfoFile}`, + `mkdir -p ${RAILPACK_PLAN_PATH}`, + `railpack prepare ${BUILD_SOURCE_PATH} --plan-out ${railpackPlanFile} --info-out ${railpackInfoFile}`, 'echo "Prepared Railpack build plan:"', `cat ${railpackInfoFile} || true`, ].join('\n'); @@ -120,29 +117,14 @@ class RailpackBuildJobBuilder implements BuildJobBuilder { command: ['bash', '-lc'], args: [script], env: [ - { - name: 'GIT_URL', - value: gitUrl, - }, - { - name: 'GIT_BRANCH', - value: ctx.app.gitBranch ?? 'main', - }, { name: 'RAILPACK_VERSION', value: railpackVersion, }, ], - volumeMounts: [{ name: sharedVolumeName, mountPath: sharedMountPath }], + volumeMounts: [{ name: BUILD_WORKSPACE_VOLUME_NAME, mountPath: BUILD_WORKSPACE_MOUNT_PATH }], }; } - - private getAuthenticatedGitUrl(ctx: BuildJobBuilderContext) { - if (ctx.app.gitUsername && ctx.app.gitToken) { - return ctx.app.gitUrl!.replace('https://', `https://${ctx.app.gitUsername}:${ctx.app.gitToken}@`); - } - return ctx.app.gitUrl!; - } } const railpackBuildJobBuilder = new RailpackBuildJobBuilder(); diff --git a/src/server/services/build-job-builders/railpack-build-job-builder.service.unit.spec.ts b/src/server/services/build-job-builders/railpack-build-job-builder.service.unit.spec.ts index 54886831..6b213fd3 100644 --- a/src/server/services/build-job-builders/railpack-build-job-builder.service.unit.spec.ts +++ b/src/server/services/build-job-builders/railpack-build-job-builder.service.unit.spec.ts @@ -25,11 +25,12 @@ describe('RailpackBuildJobBuilder', () => { expect(job.spec?.template?.metadata?.annotations?.['qs-deplyoment-id']).toBe('deployment-1'); expect(initContainers.map((container) => container.name)).toEqual([ 'build-queue-init', + 'build-git-init', 'railpack-prepare-init', ]); expect(job.spec?.template?.spec?.volumes).toEqual([ expect.objectContaining({ - name: 'railpack-workspace', + name: 'build-workspace', emptyDir: {}, }), ]); @@ -39,5 +40,9 @@ describe('RailpackBuildJobBuilder', () => { 'context=/workspace/source', 'dockerfile=/workspace/plan', ])); + + const prepareContainer = initContainers.find((container) => container.name === 'railpack-prepare-init')!; + expect(prepareContainer.env?.map((entry) => entry.name)).not.toContain('GIT_URL'); + expect(prepareContainer.args?.[0]).not.toContain('git clone'); }); }); diff --git a/src/server/services/build.service.ts b/src/server/services/build.service.ts index 58d5118b..7b884ee1 100644 --- a/src/server/services/build.service.ts +++ b/src/server/services/build.service.ts @@ -7,7 +7,7 @@ import { ServiceException } from "@/shared/model/service.exception.model"; import { Constants } from "../../shared/utils/constants"; import dataAccess from "../adapter/db.client"; import k3s from "../adapter/kubernetes-api.adapter"; -import buildInitContainerService from "./build-job-builders/build-init-container.service"; +import buildQueueInitContainer from "./build-job-builders/build-init-container.service"; import dockerfileBuildJobBuilder from "./build-job-builders/dockerfile-build-job-builder.service"; import railpackBuildJobBuilder from "./build-job-builders/railpack-build-job-builder.service"; import { BuildJobBuilder } from "./build-job-builders/build-job-builder.interface"; @@ -77,7 +77,7 @@ class BuildService { const builder = this.getBuilder(buildMethod); await dlog(deploymentId, `Creating build job with name: ${buildName}`); - await buildInitContainerService.ensureRbacResources(); + await buildQueueInitContainer.ensureRbacResources(); if (buildMethod === 'DOCKERFILE') { await dlog(deploymentId, `Dockerfile path: ${app.dockerfilePath || './Dockerfile'}`); From c1e342631801a0755819815afb75df740a7f5dbd Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 26 Apr 2026 10:45:49 +0000 Subject: [PATCH 2/9] feat: add support for adding git repositories by an SSH URL with private/public key authentication --- .../migration.sql | 11 ++ prisma/schema.prisma | 14 +- src/app/project/[projectId]/apps-table.tsx | 4 +- .../app/[appId]/app-action-buttons.tsx | 4 +- src/app/project/app/[appId]/app-tabs.tsx | 6 +- .../project/app/[appId]/general/actions.ts | 28 ++- .../app/[appId]/general/app-source.tsx | 159 +++++++++++++++++- src/app/project/app/[appId]/page.tsx | 6 +- .../services/app-git-ssh-key.service.ts | 149 ++++++++++++++++ .../app-git-ssh-key.service.unit.spec.ts | 61 +++++++ src/server/services/app.service.ts | 2 +- .../build-git-init-container.service.ts | 21 ++- ...ld-git-init-container.service.unit.spec.ts | 37 ++++ .../build-job-builder.interface.ts | 1 + .../dockerfile-build-job-builder.service.ts | 11 +- ...ile-build-job-builder.service.unit.spec.ts | 30 ++++ .../railpack-build-job-builder.service.ts | 11 +- .../build.service.builders.unit.spec.ts | 24 +++ src/server/services/build.service.ts | 34 ++-- src/server/services/git.service.ts | 14 +- src/server/services/git.service.unit.spec.ts | 73 ++++++++ src/server/services/secret.service.ts | 39 ++++- .../build-watch.service.ts | 11 +- .../build-watch.service.unit.spec.ts | 12 ++ src/shared/model/app-source-info.model.ts | 32 +++- .../model/app-source-info.model.unit.spec.ts | 34 +++- src/shared/model/generated-zod/app.ts | 4 +- .../model/generated-zod/appgitsshkey.ts | 25 +++ src/shared/model/generated-zod/index.ts | 1 + src/shared/utils/constants.ts | 2 + 30 files changed, 817 insertions(+), 43 deletions(-) create mode 100644 prisma/migrations/20260426090000_add_app_git_ssh_key/migration.sql create mode 100644 src/server/services/app-git-ssh-key.service.ts create mode 100644 src/server/services/app-git-ssh-key.service.unit.spec.ts create mode 100644 src/server/services/git.service.unit.spec.ts create mode 100644 src/shared/model/generated-zod/appgitsshkey.ts diff --git a/prisma/migrations/20260426090000_add_app_git_ssh_key/migration.sql b/prisma/migrations/20260426090000_add_app_git_ssh_key/migration.sql new file mode 100644 index 00000000..9b9439f2 --- /dev/null +++ b/prisma/migrations/20260426090000_add_app_git_ssh_key/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "AppGitSshKey" ( + "id" TEXT NOT NULL PRIMARY KEY, + "appId" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, + "encryptedPrivateKey" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppGitSshKey_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "AppGitSshKey_appId_key" ON "AppGitSshKey"("appId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c4cec355..f9daba2e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,7 +178,7 @@ model App { appType String @default("APP") // APP, POSTGRES, MYSQL, MONGO projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - sourceType String @default("GIT") // GIT, CONTAINER + sourceType String @default("GIT") // GIT, GIT_SSH, CONTAINER buildMethod String @default("RAILPACK") // RAILPACK, DOCKERFILE containerImageSource String? @@ -227,12 +227,24 @@ model App { appVolumes AppVolume[] appFileMounts AppFileMount[] appBasicAuths AppBasicAuth[] + appGitSshKey AppGitSshKey? roleAppPermissions RoleAppPermission[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model AppGitSshKey { + id String @id @default(uuid()) + appId String @unique + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + publicKey String + encryptedPrivateKey String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model AppPort { id String @id @default(uuid()) appId String diff --git a/src/app/project/[projectId]/apps-table.tsx b/src/app/project/[projectId]/apps-table.tsx index 62cce0a4..63ca8a2d 100644 --- a/src/app/project/[projectId]/apps-table.tsx +++ b/src/app/project/[projectId]/apps-table.tsx @@ -34,7 +34,7 @@ export default function AppTable({ item.sourceType === 'GIT' ? 'Git' : 'Container'], + ['sourceType', 'Source Type', false, (item) => item.sourceType === 'GIT' ? 'Git HTTPS' : item.sourceType === 'GIT_SSH' ? 'Git SSH' : 'Container'], ['replicas', 'Replica Count', false], ['memoryLimit', 'Memory Limit', false], ['memoryReservation', 'Memory Reservation', false], @@ -84,4 +84,4 @@ export default function AppTable({ } /> -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/app-action-buttons.tsx b/src/app/project/app/[appId]/app-action-buttons.tsx index 3261336b..c147a8a4 100644 --- a/src/app/project/app/[appId]/app-action-buttons.tsx +++ b/src/app/project/app/[appId]/app-action-buttons.tsx @@ -45,7 +45,7 @@ export default function AppActionButtons({
{hasWriteAccess && <> - {app.appType === 'APP' && app.sourceType === 'GIT' && } + {app.appType === 'APP' && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH') && } } @@ -59,4 +59,4 @@ export default function AppActionButtons({ ; -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index ae9e0837..79f56bde 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -34,7 +34,8 @@ export default function AppTabs({ tabName, s3Targets, volumeBackups, - nodesInfo + nodesInfo, + gitSshPublicKey, }: { app: AppExtendedModel; role: RolePermissionEnum; @@ -42,6 +43,7 @@ export default function AppTabs({ s3Targets: S3Target[]; volumeBackups: VolumeBackupExtendedModel[]; nodesInfo: NodeInfoModel[]; + gitSshPublicKey?: string; }) { const router = useRouter(); const readonly = role !== RolePermissionEnum.READWRITE; @@ -74,7 +76,7 @@ export default function AppTabs({ } - + diff --git a/src/app/project/app/[appId]/general/actions.ts b/src/app/project/app/[appId]/general/actions.ts index 9c09e2f0..3c2431aa 100644 --- a/src/app/project/app/[appId]/general/actions.ts +++ b/src/app/project/app/[appId]/general/actions.ts @@ -1,7 +1,7 @@ 'use server' import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; -import { appSourceInfoContainerZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; +import { appSourceInfoContainerZodModel, appSourceInfoGitSshZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { FormValidationException } from "@/shared/model/form-validation-exception.model"; import { ServiceException } from "@/shared/model/service.exception.model"; @@ -9,6 +9,7 @@ import appService from "@/server/services/app.service"; import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model"; import { AppContainerConfigInputModel } from "./app-container-config"; +import appGitSshKeyService from "@/server/services/app-git-ssh-key.service"; export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => { @@ -31,6 +32,25 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou id: appId, }); }); + } else if (inputData.sourceType === 'GIT_SSH') { + return saveFormAction(inputData, appSourceInfoGitSshZodModel, async (validatedData) => { + if (validatedData.buildMethod === 'DOCKERFILE' && !validatedData.dockerfilePath) { + throw new FormValidationException('Please correct the errors in the form.', { + dockerfilePath: ['Path to Dockerfile is required when using the Dockerfile build method.'], + }); + } + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + await appService.save({ + ...existingApp, + ...validatedData, + dockerfilePath: validatedData.buildMethod === 'DOCKERFILE' + ? validatedData.dockerfilePath ?? existingApp.dockerfilePath + : existingApp.dockerfilePath, + sourceType: 'GIT_SSH', + id: appId, + }); + }); } else if (inputData.sourceType === 'CONTAINER') { return saveFormAction(inputData, appSourceInfoContainerZodModel, async (validatedData) => { await isAuthorizedWriteForApp(appId); @@ -47,6 +67,12 @@ export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSou } }; +export const generateOrRegenerateGitSshKey = async (appId: string) => + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + return await appGitSshKeyService.generateOrRegenerate(appId); + }); + export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRateLimitsModel, appId: string) => saveFormAction(inputData, appRateLimitsZodModel, async (validatedData) => { if (validatedData.replicas < 1) { diff --git a/src/app/project/app/[appId]/general/app-source.tsx b/src/app/project/app/[appId]/general/app-source.tsx index 83f5a679..8932b52a 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -13,20 +13,31 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { generateOrRegenerateGitSshKey } from "./actions"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { ClipboardCopy, KeyRound, RefreshCw } from "lucide-react"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; -export default function GeneralAppSource({ app, readonly }: { +export default function GeneralAppSource({ app, readonly, gitSshPublicKey }: { app: AppExtendedModel; readonly: boolean; + gitSshPublicKey?: string; }) { + const [publicKey, setPublicKey] = useState(gitSshPublicKey); + const [isPublicKeyDialogOpen, setIsPublicKeyDialogOpen] = useState(false); + const { openConfirmDialog } = useConfirmDialog(); const form = useForm({ resolver: zodResolver(appSourceInfoInputZodModel), defaultValues: { ...app, - sourceType: app.sourceType as 'GIT' | 'CONTAINER', + sourceType: app.sourceType as 'GIT' | 'GIT_SSH' | 'CONTAINER', buildMethod: (app.buildMethod as AppBuildMethod | undefined) ?? 'RAILPACK', dockerfilePath: app.dockerfilePath ?? './Dockerfile', }, @@ -44,6 +55,30 @@ export default function GeneralAppSource({ app, readonly }: { }, [state]); const sourceTypeField = form.watch(); + const copyPublicKey = () => { + if (!publicKey) { + return; + } + navigator.clipboard.writeText(publicKey); + toast.success('Copied to clipboard.'); + }; + const generateKey = async () => { + if (publicKey) { + const confirmed = await openConfirmDialog({ + title: "Regenerate SSH Key", + description: "This replaces the current app SSH key. Update the deploy key in your git provider before deploying again.", + okButton: "Regenerate", + }); + if (!confirmed) { + return; + } + } + const result = await Toast.fromAction(() => generateOrRegenerateGitSshKey(app.id)); + if (result.status === 'success' && result.data) { + setPublicKey(result.data); + } + }; + return <> @@ -72,10 +107,11 @@ export default function GeneralAppSource({ app, readonly }: {
{ - form.setValue('sourceType', val as 'GIT' | 'CONTAINER'); + form.setValue('sourceType', val as 'GIT' | 'GIT_SSH' | 'CONTAINER'); }} className="mt-2"> - {app.appType === 'APP' && Git} + {app.appType === 'APP' && Git HTTPS} + {app.appType === 'APP' && Git SSH} Docker Container @@ -181,6 +217,99 @@ export default function GeneralAppSource({ app, readonly }: { )} + + + ( + + Git SSH Repo URL + + + + + + )} + /> +
+ ( + + Git Branch + + + + + + )} + /> + + {!readonly &&
+ +
+ {publicKey && } + +
+ {publicKey &&Add this public key as deploy key in your git provider.} +
} + + ( + + Build Method + + + + )} + /> + + {sourceTypeField.buildMethod === 'DOCKERFILE' && (<> + ( + + Path to Dockerfile + + + + + + )} + /> + )} +
+
+ + + + Public SSH Key + Add this public key as deploy key in your git provider. + +