diff --git a/Dockerfile b/Dockerfile index 74e52452..27fcf2fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ FROM node:22-alpine AS base ARG VERSION_ARG -RUN apk add --no-cache openssl +RUN apk add --no-cache openssl openssh-keygen FROM base AS deps # Install necessary packages for building -RUN apk add --no-cache libc6-compat python3 make g++ +RUN apk add --no-cache libc6-compat python3 make g++ WORKDIR /app @@ -36,7 +36,7 @@ ENV PYTHON=/usr/bin/python3 ENV QS_VERSION=$VERSION_ARG ENV DATABASE_URL="file:/app/storage/db/data.db" -RUN apk add --no-cache git +RUN apk add --no-cache git openssh-client RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs 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/__tests__/git-test-repositories.utils.ts b/src/__tests__/git-test-repositories.utils.ts new file mode 100644 index 00000000..f4b30e16 --- /dev/null +++ b/src/__tests__/git-test-repositories.utils.ts @@ -0,0 +1,51 @@ +import { AppExtendedModel } from "@/shared/model/app-extended.model"; + +export const GitTestRepositories = { + publicHttpsUrl: 'https://github.com/biersoeckli/dummy-node-app.git', + publicSshUrl: 'git@github.com:biersoeckli/dummy-node-app.git', + privateSshUrl: 'git@github.com:biersoeckli/dummy-node-app-private.git', + branch: 'main', +} as const; + +export const GitTestEnvironment = { + privateSshKey: 'INTEGRATION_TEST_GIT_PRIVATE_SSH_KEY' +} as const; + +export function getPrivateGitSshKeyFromEnv() { + return process.env[GitTestEnvironment.privateSshKey]?.replace(/\\n/g, '\n').trim(); +} + +export function createGitApp(input: Pick): AppExtendedModel { + return { + id: input.id, + name: input.id, + appType: 'APP', + projectId: 'proj-git-service-integration', + sourceType: input.sourceType, + buildMethod: 'RAILPACK', + gitUrl: input.gitUrl, + gitBranch: GitTestRepositories.branch, + dockerfilePath: './Dockerfile', + replicas: 1, + envVars: '', + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + createdAt: new Date(), + updatedAt: new Date(), + project: { + id: 'proj-git-service-integration', + name: 'Git Service Integration', + createdAt: new Date(), + updatedAt: new Date(), + }, + appDomains: [], + appPorts: [], + appFileMounts: [], + appVolumes: [], + appBasicAuths: [], + } as AppExtendedModel; +} \ No newline at end of file diff --git a/src/__tests__/integration/server/services/build.service.integration.spec.ts b/src/__tests__/integration/server/services/build.service.integration.spec.ts index 8a882cef..bacba094 100644 --- a/src/__tests__/integration/server/services/build.service.integration.spec.ts +++ b/src/__tests__/integration/server/services/build.service.integration.spec.ts @@ -5,35 +5,119 @@ mockNextJsCaching(); vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); -import path from 'node:path'; -import fs from 'node:fs/promises'; +import { getPrivateGitSshKeyFromEnv, GitTestRepositories } from '@/__tests__/git-test-repositories.utils'; import { createK3sTestContext } from '@/__tests__/k3s-test.utils'; +import { mockPathUtilsForTests } from '@/__tests__/path-test.utils'; +import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; +import dataAccess from '@/server/adapter/db.client'; +import k3s from '@/server/adapter/kubernetes-api.adapter'; import buildService from '@/server/services/build.service'; import deploymentLogService from '@/server/services/deployment-logs.service'; import paramService, { ParamService } from '@/server/services/param.service'; import podService from '@/server/services/pod.service'; import registryService, { BUILD_NAMESPACE } from '@/server/services/registry.service'; -import k3s from '@/server/adapter/kubernetes-api.adapter'; +import { CryptoUtils } from '@/server/utils/crypto.utils'; +import { PathUtils } from '@/server/utils/path.utils'; import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { AppBuildMethod } from '@/shared/model/app-source-info.model'; import { Constants } from '@/shared/utils/constants'; -import { PathUtils } from '@/server/utils/path.utils'; -import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; +import fs from 'node:fs/promises'; -const testStorageRoot = path.join(process.cwd(), 'storage'); -const originalInternalDataRoot = Object.getOwnPropertyDescriptor(PathUtils, 'internalDataRoot'); -const originalTempDataRoot = Object.getOwnPropertyDescriptor(PathUtils, 'tempDataRoot'); -Object.defineProperty(PathUtils, 'internalDataRoot', { - configurable: true, - get: () => path.join(testStorageRoot, 'internal'), -}); +describe('build.service integration', () => { + setupBuildServiceIntegration('build-service'); + + it('fails to build a docker image from an SSH repository without ssh key auth', async () => { + await runBuildAndAssertGitFailure({ + appIdPrefix: 'dockerfile-ssh-public', + projectIdPrefix: 'proj-dockerfile-ssh-public', + sourceType: 'GIT_SSH', + buildMethod: 'DOCKERFILE', + gitUrl: GitTestRepositories.publicSshUrl, + expectedLogLine: 'Dockerfile path: ./Dockerfile', + }); + }, 420_000); + + it.skipIf(!getPrivateGitSshKeyFromEnv())('builds and pushes a docker image from the private SSH repository', async () => { + await runBuildAndAssert({ + appIdPrefix: 'dockerfile-ssh-private', + projectIdPrefix: 'proj-dockerfile-ssh-private', + sourceType: 'GIT_SSH', + buildMethod: 'DOCKERFILE', + gitUrl: GitTestRepositories.privateSshUrl, + expectedLogLine: 'Dockerfile path: ./Dockerfile', + privateSshKey: getRequiredPrivateGitSshKey(), + }); + }, 420_000); + + it.skipIf(!getPrivateGitSshKeyFromEnv())('builds and pushes a railpack image from the private SSH repository', async () => { + await runBuildAndAssert({ + appIdPrefix: 'railpack-ssh-private', + projectIdPrefix: 'proj-railpack-ssh-private', + sourceType: 'GIT_SSH', + buildMethod: 'RAILPACK', + gitUrl: GitTestRepositories.privateSshUrl, + expectedLogLine: 'Railpack build will run queue wait, prepare step, and BuildKit build in sequence.', + privateSshKey: getRequiredPrivateGitSshKey(), + }); + }, 420_000); + -Object.defineProperty(PathUtils, 'tempDataRoot', { - configurable: true, - get: () => path.join(testStorageRoot, 'tmp'), + it('builds and pushes a docker image from the public HTTPS repository', async () => { + await runBuildAndAssert({ + appIdPrefix: 'dockerfile-http', + projectIdPrefix: 'proj-dockerfile-http', + sourceType: 'GIT', + buildMethod: 'DOCKERFILE', + gitUrl: GitTestRepositories.publicHttpsUrl, + expectedLogLine: 'Dockerfile path: ./Dockerfile', + }); + }, 420_000); + + it('builds and pushes a railpack image from the public HTTPS repository', async () => { + await runBuildAndAssert({ + appIdPrefix: 'railpack-http', + projectIdPrefix: 'proj-railpack-http', + sourceType: 'GIT', + buildMethod: 'RAILPACK', + gitUrl: GitTestRepositories.publicHttpsUrl, + expectedLogLine: 'Railpack build will run queue wait, prepare step, and BuildKit build in sequence.', + }); + }, 420_000); }); -async function deployRegistry() { +export type BuildIntegrationInput = { + appIdPrefix: string; + projectIdPrefix: string; + buildMethod: AppBuildMethod; + sourceType: 'GIT' | 'GIT_SSH'; + gitUrl: string; + expectedLogLine: string; + privateSshKey?: string; +}; + +export function setupBuildServiceIntegration(label: string) { + const { originalInternalDataRoot, originalTempDataRoot } = mockPathUtilsForTests(); + createK3sTestContext(); + createPrismaTestContext(label); + + beforeEach(() => { + process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? 'test-nextauth-secret'; + vi.clearAllMocks(); + }); + + afterAll(() => { + if (originalInternalDataRoot) { + Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot); + } + if (originalTempDataRoot) { + Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot); + } + vi.restoreAllMocks(); + }); +} + +export async function deployRegistryForBuildIntegration() { await paramService.save({ name: ParamService.BUILD_NODE, value: Constants.BUILD_NODE_K3S_NATIVE_VALUE, @@ -58,185 +142,159 @@ async function deployRegistry() { expect(registryDeployments.body.items.some((item) => item.metadata?.name === 'registry')).toBe(true); } -describe('build.service integration', () => { - createK3sTestContext(); - createPrismaTestContext('build-service-integration'); +export async function runBuildAndAssert(input: BuildIntegrationInput) { + await deployRegistryForBuildIntegration(); - afterAll(() => { - if (originalInternalDataRoot) { - Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot); - } - if (originalTempDataRoot) { - Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot); - } - vi.restoreAllMocks(); + const suffix = Date.now(); + const app = createBuildApp({ + ...input, + id: `${input.appIdPrefix}-${suffix}`, + projectId: `${input.projectIdPrefix}-${suffix}`, }); - beforeEach(() => { - vi.clearAllMocks(); + if (input.privateSshKey) { + await persistAppAndSshKey(app, input.privateSshKey); + } + + const deploymentId = `dep-${suffix}`; + const [buildJobName, gitCommitHash, gitCommitMessage, shouldDeployImmediately] = + await deploymentLogService.catchErrosAndLog(deploymentId, async () => buildService.buildApp(deploymentId, app, true)); + + expect(shouldDeployImmediately).toBe(false); + expect(buildJobName).toMatch(new RegExp(`^build-${app.id}`)); + expect(gitCommitHash).toMatch(/^[0-9a-f]{40}$/); + expect(gitCommitMessage.length).toBeGreaterThan(0); + + await expect.poll(async () => { + return await buildService.getJobStatus(buildJobName); + }, { + timeout: 300_000, + interval: 2_000, + }).toBe('SUCCEEDED'); + + const registryPods = await podService.getPodsForApp(BUILD_NAMESPACE, 'registry'); + expect(registryPods).toHaveLength(1); + + await podService.runCommandInPod( + BUILD_NAMESPACE, + registryPods[0].podName, + registryPods[0].containerName, + [ + 'sh', + '-c', + `test -f /var/lib/registry/docker/registry/v2/repositories/${app.id}/_manifests/tags/latest/current/link`, + ], + ); + + const builds = await buildService.getBuildsForApp(app.id); + expect(builds[0]).toMatchObject({ + name: buildJobName, + status: 'SUCCEEDED', + buildMethod: input.buildMethod, + gitCommit: gitCommitHash, }); - it('builds and pushes a railpack image for the dummy node app repository', async () => { + const logFile = await fs.readFile(PathUtils.appDeploymentLogFile(deploymentId), 'utf-8'); + expect(logFile).toContain(`Selected build method: ${input.buildMethod}`); + expect(logFile).toContain(input.expectedLogLine); + expect(logFile).toContain(`Build job ${buildJobName} scheduled successfully`); +} - await deployRegistry(); +export async function runBuildAndAssertGitFailure(input: BuildIntegrationInput) { + await deployRegistryForBuildIntegration(); - const suffix = Date.now(); - const app: AppExtendedModel = { - id: `railpack-dummy-${suffix}`, - name: `railpack-dummy-${suffix}`, - appType: 'APP', - projectId: `proj-railpack-${suffix}`, - sourceType: 'GIT', - buildMethod: 'RAILPACK', - dockerfilePath: './Dockerfile', - gitUrl: 'https://github.com/biersoeckli/dummy-node-app.git', - gitBranch: 'main', - replicas: 1, - envVars: '', - ingressNetworkPolicy: 'ALLOW_ALL', - egressNetworkPolicy: 'ALLOW_ALL', - useNetworkPolicy: true, - healthCheckPeriodSeconds: 15, - healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, - createdAt: new Date(), - updatedAt: new Date(), - project: { - id: `proj-railpack-${suffix}`, - name: `Railpack Build Project ${suffix}`, - createdAt: new Date(), - updatedAt: new Date(), - }, - appDomains: [], - appPorts: [], - appFileMounts: [], - appVolumes: [], - appBasicAuths: [], - }; - - - const deploymentId = `dep-${Date.now()}`; - const [buildJobName, gitCommitHash, gitCommitMessage, shouldDeployImmediately] = - await deploymentLogService.catchErrosAndLog(deploymentId, async () => buildService.buildApp(deploymentId, app, true)); - - expect(shouldDeployImmediately).toBe(false); - expect(buildJobName).toMatch(new RegExp(`^build-${app.id}`)); - expect(gitCommitHash).toMatch(/^[0-9a-f]{40}$/); - expect(gitCommitMessage.length).toBeGreaterThan(0); - - await expect.poll(async () => { - return await buildService.getJobStatus(buildJobName); - }, { - timeout: 300_000, - interval: 2_000, - }).toBe('SUCCEEDED'); - - const registryPods = await podService.getPodsForApp(BUILD_NAMESPACE, 'registry'); - expect(registryPods).toHaveLength(1); - - await podService.runCommandInPod( - BUILD_NAMESPACE, - registryPods[0].podName, - registryPods[0].containerName, - [ - 'sh', - '-c', - `test -f /var/lib/registry/docker/registry/v2/repositories/${app.id}/_manifests/tags/latest/current/link`, - ], - ); - - const builds = await buildService.getBuildsForApp(app.id); - expect(builds[0]).toMatchObject({ - name: buildJobName, - status: 'SUCCEEDED', - buildMethod: 'RAILPACK', - gitCommit: gitCommitHash, - }); + const suffix = Date.now(); + const app = createBuildApp({ + ...input, + id: `${input.appIdPrefix}-${suffix}`, + projectId: `${input.projectIdPrefix}-${suffix}`, + }); - const logFile = await fs.readFile(PathUtils.appDeploymentLogFile(deploymentId), 'utf-8'); - expect(logFile).toContain('Selected build method: RAILPACK'); - expect(logFile).toContain(`Build job ${buildJobName} scheduled successfully`); - }, 420_000); + const deploymentId = `dep-${suffix}`; + await expect(deploymentLogService.catchErrosAndLog(deploymentId, async () => buildService.buildApp(deploymentId, app, true))) + .rejects + .toThrow('Git: SSH host key verification failed.'); - it('builds and pushes a docker image frma dockerfile using the dummy node app repository', async () => { + const builds = await buildService.getBuildsForApp(app.id); + expect(builds).toHaveLength(0); +} - await deployRegistry(); +export function getRequiredPrivateGitSshKey() { + const privateSshKey = getPrivateGitSshKeyFromEnv(); + if (!privateSshKey) { + throw new Error('Missing private SSH key for integration test.'); + } + return privateSshKey; +} - const suffix = Date.now(); - const app: AppExtendedModel = { - id: `dockerfile-dummy-${suffix}`, - name: `dockerfile-dummy-${suffix}`, - appType: 'APP', - projectId: `proj-dockerfile-${suffix}`, - sourceType: 'GIT', - buildMethod: 'DOCKERFILE', - dockerfilePath: './Dockerfile', - gitUrl: 'https://github.com/biersoeckli/dummy-node-app.git', - gitBranch: 'main', - replicas: 1, - envVars: '', - ingressNetworkPolicy: 'ALLOW_ALL', - egressNetworkPolicy: 'ALLOW_ALL', - useNetworkPolicy: true, - healthCheckPeriodSeconds: 15, - healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, +function createBuildApp(input: BuildIntegrationInput & { id: string; projectId: string }): AppExtendedModel { + return { + id: input.id, + name: input.id, + appType: 'APP', + projectId: input.projectId, + sourceType: input.sourceType, + buildMethod: input.buildMethod, + dockerfilePath: './Dockerfile', + gitUrl: input.gitUrl, + gitBranch: GitTestRepositories.branch, + replicas: 1, + envVars: '', + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + createdAt: new Date(), + updatedAt: new Date(), + project: { + id: input.projectId, + name: input.projectId, createdAt: new Date(), updatedAt: new Date(), - project: { - id: `proj-dockerfile-${suffix}`, - name: `Dockerfile Build Project ${suffix}`, - createdAt: new Date(), - updatedAt: new Date(), - }, - appDomains: [], - appPorts: [], - appFileMounts: [], - appVolumes: [], - appBasicAuths: [], - }; - - - const deploymentId = `dep-${Date.now()}`; - const [buildJobName, gitCommitHash, gitCommitMessage, shouldDeployImmediately] = - await deploymentLogService.catchErrosAndLog(deploymentId, async () => buildService.buildApp(deploymentId, app, true)); - - expect(shouldDeployImmediately).toBe(false); - expect(buildJobName).toMatch(new RegExp(`^build-${app.id}`)); - expect(gitCommitHash).toMatch(/^[0-9a-f]{40}$/); - expect(gitCommitMessage.length).toBeGreaterThan(0); - - await expect.poll(async () => { - return await buildService.getJobStatus(buildJobName); - }, { - timeout: 300_000, - interval: 2_000, - }).toBe('SUCCEEDED'); - - const registryPods = await podService.getPodsForApp(BUILD_NAMESPACE, 'registry'); - expect(registryPods).toHaveLength(1); - - await podService.runCommandInPod( - BUILD_NAMESPACE, - registryPods[0].podName, - registryPods[0].containerName, - [ - 'sh', - '-c', - `test -f /var/lib/registry/docker/registry/v2/repositories/${app.id}/_manifests/tags/latest/current/link`, - ], - ); - - const builds = await buildService.getBuildsForApp(app.id); - expect(builds[0]).toMatchObject({ - name: buildJobName, - status: 'SUCCEEDED', - buildMethod: 'DOCKERFILE', - gitCommit: gitCommitHash, - }); + }, + appDomains: [], + appPorts: [], + appFileMounts: [], + appVolumes: [], + appBasicAuths: [], + }; +} - const logFile = await fs.readFile(PathUtils.appDeploymentLogFile(deploymentId), 'utf-8'); - expect(logFile).toContain('Selected build method: DOCKERFILE'); - expect(logFile).toContain(`Build job ${buildJobName} scheduled successfully`); - }, 420_000); -}); +async function persistAppAndSshKey(app: AppExtendedModel, privateSshKey: string) { + await dataAccess.client.project.create({ + data: { + id: app.projectId, + name: app.project.name, + }, + }); + await dataAccess.client.app.create({ + data: { + id: app.id, + name: app.name, + appType: app.appType, + projectId: app.projectId, + sourceType: app.sourceType, + buildMethod: app.buildMethod, + gitUrl: app.gitUrl, + gitBranch: app.gitBranch, + dockerfilePath: app.dockerfilePath, + replicas: app.replicas, + envVars: app.envVars, + ingressNetworkPolicy: app.ingressNetworkPolicy, + egressNetworkPolicy: app.egressNetworkPolicy, + useNetworkPolicy: app.useNetworkPolicy, + healthCheckPeriodSeconds: app.healthCheckPeriodSeconds, + healthCheckTimeoutSeconds: app.healthCheckTimeoutSeconds, + healthCheckFailureThreshold: app.healthCheckFailureThreshold, + }, + }); + await dataAccess.client.appGitSshKey.create({ + data: { + appId: app.id, + publicKey: 'ssh-ed25519 integration-test-placeholder', + encryptedPrivateKey: CryptoUtils.encrypt(privateSshKey), + }, + }); +} diff --git a/src/__tests__/integration/server/services/git.service.integration.spec.ts b/src/__tests__/integration/server/services/git.service.integration.spec.ts new file mode 100644 index 00000000..e6820c61 --- /dev/null +++ b/src/__tests__/integration/server/services/git.service.integration.spec.ts @@ -0,0 +1,86 @@ +// @vitest-environment node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { mockPathUtilsForTests } from '@/__tests__/path-test.utils'; +import { GitTestRepositories, createGitApp, getPrivateGitSshKeyFromEnv } from '@/__tests__/git-test-repositories.utils'; +import { PathUtils } from '@/server/utils/path.utils'; + +const sshKeyTempDirs: string[] = []; +const appGitSshKeyServiceMock = vi.hoisted(() => ({ + writePrivateKeyToTempFile: vi.fn(), + cleanupTempKeyFile: vi.fn(), +})); + +vi.mock('@/server/services/app-git-ssh-key.service', () => ({ + default: appGitSshKeyServiceMock, +})); + +import gitService from '@/server/services/git.service'; + +const { originalInternalDataRoot, originalTempDataRoot } = mockPathUtilsForTests(); + +async function writePrivateKeyToTempFile(privateKey: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'quickstack-git-service-key-')); + sshKeyTempDirs.push(dir); + const keyPath = path.join(dir, 'id_ed25519'); + await fs.writeFile(keyPath, `${privateKey}\n`, { mode: 0o600 }); + await fs.chmod(keyPath, 0o600); + return keyPath; +} + +async function expectRepositoryCloneWorks(app: AppExtendedModel) { + await gitService.openGitContext(app, async (ctx) => { + await expect(ctx.getLatestRemoteCommitHash()).resolves.toMatch(/^[0-9a-f]{40}$/); + await expect(ctx.getLatestRemoteCommitMessage()).resolves.toEqual(expect.any(String)); + }); +} + +describe('git.service integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + appGitSshKeyServiceMock.writePrivateKeyToTempFile.mockResolvedValue(undefined); + appGitSshKeyServiceMock.cleanupTempKeyFile.mockResolvedValue(undefined); + }); + + afterAll(async () => { + if (originalInternalDataRoot) { + Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot); + } + if (originalTempDataRoot) { + Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot); + } + await Promise.all(sshKeyTempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + vi.restoreAllMocks(); + }); + + it('clones the public repository over HTTPS', async () => { + await expectRepositoryCloneWorks(createGitApp({ + id: 'git-public-https', + sourceType: 'GIT', + gitUrl: GitTestRepositories.publicHttpsUrl, + })); + + expect(appGitSshKeyServiceMock.writePrivateKeyToTempFile).not.toHaveBeenCalled(); + }, 120_000); + + it.skipIf(!getPrivateGitSshKeyFromEnv())( + 'clones the private repository over SSH with an app key', + async () => { + const privateKey = getPrivateGitSshKeyFromEnv()!; + appGitSshKeyServiceMock.writePrivateKeyToTempFile.mockImplementation(() => writePrivateKeyToTempFile(privateKey)); + + await expectRepositoryCloneWorks(createGitApp({ + id: 'git-private-ssh', + sourceType: 'GIT_SSH', + gitUrl: GitTestRepositories.privateSshUrl, + })); + + expect(appGitSshKeyServiceMock.writePrivateKeyToTempFile).toHaveBeenCalledWith('git-private-ssh'); + expect(appGitSshKeyServiceMock.cleanupTempKeyFile).toHaveBeenCalledWith('git-private-ssh'); + }, + 120_000, + ); +}); diff --git a/src/__tests__/path-test.utils.ts b/src/__tests__/path-test.utils.ts new file mode 100644 index 00000000..fc26a737 --- /dev/null +++ b/src/__tests__/path-test.utils.ts @@ -0,0 +1,19 @@ +import { PathUtils } from "@/server/utils/path.utils"; +import path from "path"; + +export function mockPathUtilsForTests() { + const testStorageRoot = path.join(process.cwd(), 'storage'); + const originalInternalDataRoot = Object.getOwnPropertyDescriptor(PathUtils, 'internalDataRoot'); + const originalTempDataRoot = Object.getOwnPropertyDescriptor(PathUtils, 'tempDataRoot'); + + Object.defineProperty(PathUtils, 'internalDataRoot', { + configurable: true, + get: () => path.join(testStorageRoot, 'internal'), + }); + + Object.defineProperty(PathUtils, 'tempDataRoot', { + configurable: true, + get: () => path.join(testStorageRoot, 'tmp'), + }); + return { originalInternalDataRoot, originalTempDataRoot }; +} \ No newline at end of file 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..95857b97 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,29 @@ 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 publicKey = await appGitSshKeyService.getPublicKey(appId); + if (!publicKey) { + throw new ServiceException('Generate SSH keys before saving a Git SSH source.'); + } + 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 +71,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..41c5b6ff 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -13,20 +13,34 @@ 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, GitBranch, Info, KeyRound, Package, 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"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Actions } from "@/frontend/utils/nextjs-actions.utils"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -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 +58,36 @@ 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 formIsValid = await form.trigger(); + if (!formIsValid) { + return; + } + const result = await Actions.run(() => generateOrRegenerateGitSshKey(app.id)); + setPublicKey(result); + + const saveResult = await Toast.fromAction(() => saveGeneralAppSourceInfo(undefined, form.getValues(), app.id), 'Successfully generated SSH keys and saved Git SSH source info.', 'Failed to generate SSH keys and save Git SSH source info.'); + FormUtils.mapValidationErrorsToForm(saveResult, form); + }; + return <> @@ -72,12 +116,17 @@ 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} - Docker Container - + + + + {app.appType === 'APP' && Git HTTPS} + {app.appType === 'APP' && Git SSH} + Docker Container + + + )} /> -
+
)}
+ + + + + + SSH access requires a known key + + Git providers like GitHub require an accepted SSH key for SSH clone URLs, even for public repositories. Generate keys and add the public key as a deploy key, or use HTTPS for anonymous public clones. + + + ( + + 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. + +