diff --git a/prisma/migrations/20260424110000_add_build_method/migration.sql b/prisma/migrations/20260424110000_add_build_method/migration.sql new file mode 100644 index 00000000..6c6936c5 --- /dev/null +++ b/prisma/migrations/20260424110000_add_build_method/migration.sql @@ -0,0 +1,6 @@ +-- Add buildMethod for Git-based builds. Existing Git apps keep Dockerfile behavior. +ALTER TABLE "App" ADD COLUMN "buildMethod" TEXT NOT NULL DEFAULT 'RAILPACK'; + +UPDATE "App" +SET "buildMethod" = 'DOCKERFILE' +WHERE "sourceType" = 'GIT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576e7c12..c4cec355 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -179,6 +179,7 @@ model App { projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) sourceType String @default("GIT") // GIT, CONTAINER + buildMethod String @default("RAILPACK") // RAILPACK, DOCKERFILE containerImageSource String? containerRegistryUsername String? diff --git a/src/__tests__/integration/server/services/build.service.integration.spec.ts b/src/__tests__/integration/server/services/build.service.integration.spec.ts new file mode 100644 index 00000000..8a882cef --- /dev/null +++ b/src/__tests__/integration/server/services/build.service.integration.spec.ts @@ -0,0 +1,242 @@ +// @vitest-environment node + +import mockNextJsCaching from '@/__tests__/nextjs-cache.utils'; +mockNextJsCaching(); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { createK3sTestContext } from '@/__tests__/k3s-test.utils'; +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 { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { Constants } from '@/shared/utils/constants'; +import { PathUtils } from '@/server/utils/path.utils'; +import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; + +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'), +}); + +async function deployRegistry() { + await paramService.save({ + name: ParamService.BUILD_NODE, + value: Constants.BUILD_NODE_K3S_NATIVE_VALUE, + }); + + await registryService.deployRegistry(Constants.INTERNAL_REGISTRY_LOCATION, true); + + await expect.poll(async () => { + const pods = await podService.getPodsForApp(BUILD_NAMESPACE, 'registry'); + if (pods.length !== 1) { + return 'MISSING'; + } + + const pod = await k3s.core.readNamespacedPod(pods[0].podName, BUILD_NAMESPACE); + return pod.body.status?.phase ?? 'UNKNOWN'; + }, { + timeout: 120_000, + interval: 2_000, + }).toBe('Running'); + + const registryDeployments = await k3s.apps.listNamespacedDeployment(BUILD_NAMESPACE); + expect(registryDeployments.body.items.some((item) => item.metadata?.name === 'registry')).toBe(true); +} + +describe('build.service integration', () => { + createK3sTestContext(); + createPrismaTestContext('build-service-integration'); + + afterAll(() => { + if (originalInternalDataRoot) { + Object.defineProperty(PathUtils, 'internalDataRoot', originalInternalDataRoot); + } + if (originalTempDataRoot) { + Object.defineProperty(PathUtils, 'tempDataRoot', originalTempDataRoot); + } + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('builds and pushes a railpack image for the dummy node app repository', async () => { + + await deployRegistry(); + + 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 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); + + it('builds and pushes a docker image frma dockerfile using the dummy node app repository', async () => { + + await deployRegistry(); + + 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, + 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, + }); + + 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); +}); diff --git a/src/__tests__/k3s-test.utils.ts b/src/__tests__/k3s-test.utils.ts index b1bdae1d..bf2b79b9 100644 --- a/src/__tests__/k3s-test.utils.ts +++ b/src/__tests__/k3s-test.utils.ts @@ -86,7 +86,27 @@ export function createK3sTestContext(image = DEFAULT_IMAGE) { // Auto-wire K3sApiAdapter with test cluster clients. // Works whether the adapter is mocked ({ default: {} }) or real. const { default: k3sAdapter } = await import('@/server/adapter/kubernetes-api.adapter'); - Object.assign(k3sAdapter, getClients()); + Object.assign(k3sAdapter, getClients(), { + getKubeConfig, + applyResource: async (spec: any, namespace: string) => { + if (!spec?.kind) { + throw new Error('Invalid resource specification'); + } + + const targetNamespace = spec.metadata?.namespace || namespace; + if (!targetNamespace) { + throw new Error('Namespace is required in resource metadata in method applyResource'); + } + + const client = k8s.KubernetesObjectApi.makeApiClient(getKubeConfig()); + try { + await client.read(spec); + await client.patch(spec); + } catch { + await client.create(spec); + } + }, + }); }, K3S_HOOK_TIMEOUT_MS); it('healthcheck for k3s cluster', async () => { diff --git a/src/app/api/deployment-status/route.ts b/src/app/api/deployment-status/route.ts index 168d5bf2..467a583b 100644 --- a/src/app/api/deployment-status/route.ts +++ b/src/app/api/deployment-status/route.ts @@ -1,5 +1,6 @@ import k3s from "@/server/adapter/kubernetes-api.adapter"; import deploymentLiveStatusService from "@/server/services/deployment-live-status.service"; +import buildPodLogWatchService from "@/server/services/standalone-services/build-pod-log-watch.service"; import buildWatchService from "@/server/services/standalone-services/build-watch.service"; import deploymentEventWatchService from "@/server/services/standalone-services/deployment-event-watch.service"; import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils"; @@ -14,7 +15,7 @@ export async function POST(request: Request) { const session = await getAuthUserSession(); - // starts the buildwatch service if not already running. + buildPodLogWatchService.startWatch(); buildWatchService.startWatch(); deploymentEventWatchService.startWatch(); @@ -117,4 +118,4 @@ export async function POST(request: Request) { }, }); }); -} \ No newline at end of file +} diff --git a/src/app/api/init/route.ts b/src/app/api/init/route.ts index 499e3a28..9f81964a 100644 --- a/src/app/api/init/route.ts +++ b/src/app/api/init/route.ts @@ -1,4 +1,5 @@ import paramService, { ParamService } from "@/server/services/param.service"; +import buildPodLogWatchService from "@/server/services/standalone-services/build-pod-log-watch.service"; import buildWatchService from "@/server/services/standalone-services/build-watch.service"; import deploymentEventWatchService from "@/server/services/standalone-services/deployment-event-watch.service"; import { simpleRoute } from "@/server/utils/action-wrapper.utils"; @@ -17,6 +18,7 @@ export async function GET(request: Request) { } await buildWatchService.startWatch(); + await buildPodLogWatchService.startWatch(); await deploymentEventWatchService.startWatch(); const instanceId = await paramService.getOrCreate(ParamService.QS_INSTANCE_ID, crypto.randomUUID()); diff --git a/src/app/api/pod-logs/route.ts b/src/app/api/pod-logs/route.ts index e45961c3..fc6a6c1e 100644 --- a/src/app/api/pod-logs/route.ts +++ b/src/app/api/pod-logs/route.ts @@ -12,7 +12,6 @@ export const dynamic = "force-dynamic"; const zodInputModel = z.object({ namespace: z.string().optional(), podName: z.string().optional(), - buildJobName: z.string().optional(), linesCount: z.number().optional().default(100), }); @@ -21,18 +20,13 @@ export async function POST(request: Request) { const input = await request.json(); const podInfo = zodInputModel.parse(input); - let { namespace, podName, buildJobName, linesCount } = podInfo; + let { namespace, podName, linesCount } = podInfo; let pod; let streamKey; if (namespace && podName) { pod = await podService.getPodInfoByName(namespace, podName); streamKey = `${namespace}_${podName}`; - } else if (buildJobName) { - namespace = BUILD_NAMESPACE; - pod = await buildService.getPodForJob(buildJobName); - streamKey = `${buildJobName}`; - } else { console.error('Invalid pod info for streaming logs', podInfo); return new Response("Invalid pod info", { status: 400 }); @@ -55,7 +49,7 @@ export async function POST(request: Request) { k3sStreamRequest = await k3s.log.log(namespace, pod.podName, pod.containerName, logStream, { follow: true, - tailLines: namespace === BUILD_NAMESPACE ? undefined : linesCount, + tailLines: linesCount, timestamps: true, pretty: false, previous: false diff --git a/src/app/builds/builds-table.tsx b/src/app/builds/builds-table.tsx index 278464c6..23ea668c 100644 --- a/src/app/builds/builds-table.tsx +++ b/src/app/builds/builds-table.tsx @@ -80,6 +80,11 @@ export default function BuildsTable({ )], ['name', 'Build Job', false], + ['buildMethod', 'Build Method', true, (item) => ( + + {item.buildMethod === 'DOCKERFILE' ? 'Dockerfile' : 'Railpack'} + + )], ['startTime', 'Started At', true, (item) => formatDateTime(item.startTime)], ['completionTime', 'Duration', true, (item) => { const start = new Date(item.startTime).getTime(); diff --git a/src/app/project/app/[appId]/general/actions.ts b/src/app/project/app/[appId]/general/actions.ts index a33f3b07..9c09e2f0 100644 --- a/src/app/project/app/[appId]/general/actions.ts +++ b/src/app/project/app/[appId]/general/actions.ts @@ -3,6 +3,7 @@ import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; import { appSourceInfoContainerZodModel, 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"; import appService from "@/server/services/app.service"; import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; @@ -13,11 +14,19 @@ import { AppContainerConfigInputModel } from "./app-container-config"; export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => { if (inputData.sourceType === 'GIT') { return saveFormAction(inputData, appSourceInfoGitZodModel, 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', id: appId, }); diff --git a/src/app/project/app/[appId]/general/app-source.tsx b/src/app/project/app/[appId]/general/app-source.tsx index 327d0255..83f5a679 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -4,7 +4,7 @@ import { SubmitButton } from "@/components/custom/submit-button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/shared/model/app-source-info.model"; +import { AppBuildMethod, AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/shared/model/app-source-info.model"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { saveGeneralAppSourceInfo } from "./actions"; @@ -14,9 +14,9 @@ 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 { App } from "@prisma/client"; import { toast } from "sonner"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; export default function GeneralAppSource({ app, readonly }: { app: AppExtendedModel; @@ -26,7 +26,9 @@ export default function GeneralAppSource({ app, readonly }: { resolver: zodResolver(appSourceInfoInputZodModel), defaultValues: { ...app, - sourceType: app.sourceType as 'GIT' | 'CONTAINER' + sourceType: app.sourceType as 'GIT' | 'CONTAINER', + buildMethod: (app.buildMethod as AppBuildMethod | undefined) ?? 'RAILPACK', + dockerfilePath: app.dockerfilePath ?? './Dockerfile', }, disabled: readonly, }); @@ -92,6 +94,7 @@ export default function GeneralAppSource({ app, readonly }: { />
+ )} /> + ( - Path to Dockerfile - - - + Build Method + )} /> -
+ {sourceTypeField.buildMethod === 'DOCKERFILE' && (<> +
+ ( + + Path to Dockerfile + + + + + + )} + /> + )} + @@ -208,4 +240,4 @@ export default function GeneralAppSource({ app, readonly }: { ; -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/overview/deployments.tsx b/src/app/project/app/[appId]/overview/deployments.tsx index 7e6c1425..0f2cae58 100644 --- a/src/app/project/app/[appId]/overview/deployments.tsx +++ b/src/app/project/app/[appId]/overview/deployments.tsx @@ -82,6 +82,11 @@ export default function BuildsTab({ ['buildJobName', 'Build Job Name', false], ['deploymentId', 'Deployment Id', false], ['status', 'Status', true, (item) => {item.status}], + ['buildMethod', 'Build Method', true, (item) => ( + + {item.buildMethod ? (item.buildMethod === 'DOCKERFILE' ? 'Dockerfile' : 'Railpack') : '—'} + + )], ["startTime", "Started At", true, (item) => formatDateTime(item.createdAt)], ['gitCommit', 'Git Commit', true, (item) => {item.gitCommit}], ['gitCommitMessage', 'Commit Message', true, (item) => {item.gitCommitMessage ?? ''}], diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index 24528f09..31feb326 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -35,7 +35,14 @@ class AppService { const [buildJobName, gitCommitHash, gitCommitMessage, shouldDeployImmediately] = await buildService.buildApp(deploymentId, app, forceBuild); if (shouldDeployImmediately) { dlog(deploymentId, `Starting deployment with output from build "${buildJobName}"`); - await deploymentService.createDeployment(deploymentId, app, buildJobName, gitCommitHash, gitCommitMessage); + await deploymentService.createDeployment( + deploymentId, + app, + buildJobName, + gitCommitHash, + gitCommitMessage, + app.buildMethod === 'DOCKERFILE' ? 'DOCKERFILE' : 'RAILPACK', + ); } // Otherwise the build-watch service will trigger the deployment once the build job completes } else { diff --git a/src/server/services/build-init-container.service.ts b/src/server/services/build-job-builders/build-init-container.service.ts similarity index 94% rename from src/server/services/build-init-container.service.ts rename to src/server/services/build-job-builders/build-init-container.service.ts index 6e8168af..00768e33 100644 --- a/src/server/services/build-init-container.service.ts +++ b/src/server/services/build-job-builders/build-init-container.service.ts @@ -1,6 +1,7 @@ import { V1Container } from "@kubernetes/client-node"; -import k3s from "../adapter/kubernetes-api.adapter"; -import { BUILD_NAMESPACE } from "./registry.service"; +import k3s from "../../adapter/kubernetes-api.adapter"; +import { BUILD_NAMESPACE } from "../registry.service"; +import { Constants } from "@/shared/utils/constants"; const SERVICE_ACCOUNT_NAME = 'qs-build-watcher'; const ROLE_NAME = 'qs-build-watcher-role'; @@ -82,7 +83,7 @@ class BuildInitContainerService { ].join('\n'); return { - name: 'build-queue-init', + name: Constants.QS_BUILD_INIT_CONTAINER_NAME, image: 'bitnami/kubectl:latest', command: ['sh', '-c'], args: [script], diff --git a/src/server/services/build-job-builders/build-job-builder.interface.ts b/src/server/services/build-job-builders/build-job-builder.interface.ts new file mode 100644 index 00000000..9337cde6 --- /dev/null +++ b/src/server/services/build-job-builders/build-job-builder.interface.ts @@ -0,0 +1,19 @@ +import { V1Job, V1ResourceRequirements } from "@kubernetes/client-node"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { AppBuildMethod } from "@/shared/model/app-source-info.model"; + +export type BuildJobBuilderContext = { + app: AppExtendedModel; + buildName: string; + deploymentId: string; + latestRemoteGitHash: string; + latestRemoteGitCommitMessage: string; + queuedAt: string; + nodeSelector?: Record; + resources?: V1ResourceRequirements; +}; + +export interface BuildJobBuilder { + readonly buildMethod: AppBuildMethod; + buildJobDefinition(ctx: BuildJobBuilderContext): Promise; +} 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 new file mode 100644 index 00000000..6a85fea8 --- /dev/null +++ b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.ts @@ -0,0 +1,91 @@ +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 registryService, { BUILD_NAMESPACE } from "../registry.service"; +import { PathUtils } from "@/server/utils/path.utils"; + +const buildkitImage = "moby/buildkit:master"; + +class DockerfileBuildJobBuilder implements BuildJobBuilder { + readonly buildMethod: AppBuildMethod = 'DOCKERFILE'; + + 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 buildkitArgs = [ + "build", + "--frontend", + "dockerfile.v0", + "--opt", + `filename=${contextPaths.filePath}`, + "--opt", + `context=${gitContextUrl}`, + "--output", + `type=image,name=${registryService.createInternalContainerRegistryUrlForAppId(ctx.app.id)},push=true,registry.insecure=true` + ]; + + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: ctx.buildName, + namespace: BUILD_NAMESPACE, + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: ctx.app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: ctx.app.projectId, + [Constants.QS_ANNOTATION_GIT_COMMIT]: ctx.latestRemoteGitHash, + [Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]: ctx.latestRemoteGitCommitMessage.substring(0, 200), + [Constants.QS_ANNOTATION_DEPLOYMENT_ID]: ctx.deploymentId, + [Constants.QS_ANNOTATION_BUILD_QUEUED_AT]: ctx.queuedAt, + [Constants.QS_ANNOTATION_BUILD_METHOD]: this.buildMethod, + } + }, + spec: { + ttlSecondsAfterFinished: 86400, + template: { + metadata: { + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: ctx.app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: ctx.app.projectId, + [Constants.QS_ANNOTATION_GIT_COMMIT]: ctx.latestRemoteGitHash, + [Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]: ctx.latestRemoteGitCommitMessage.substring(0, 200), + [Constants.QS_ANNOTATION_DEPLOYMENT_ID]: ctx.deploymentId, + [Constants.QS_ANNOTATION_BUILD_METHOD]: this.buildMethod, + }, + }, + spec: { + hostUsers: false, + serviceAccountName: 'qs-build-watcher', + initContainers: [buildInitContainerService.getInitContainer(ctx.buildName, ctx.queuedAt)], + ...(ctx.nodeSelector ? { nodeSelector: ctx.nodeSelector } : {}), + containers: [ + { + name: ctx.buildName, + image: buildkitImage, + command: ["buildctl-daemonless.sh"], + args: buildkitArgs, + securityContext: { + privileged: true + }, + ...(ctx.resources ? { resources: ctx.resources } : {}), + }, + ], + restartPolicy: "Never", + }, + }, + backoffLimit: 0, + }, + }; + } +} + +const dockerfileBuildJobBuilder = new DockerfileBuildJobBuilder(); +export default 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 new file mode 100644 index 00000000..3a676863 --- /dev/null +++ b/src/server/services/build-job-builders/dockerfile-build-job-builder.service.unit.spec.ts @@ -0,0 +1,32 @@ +import dockerfileBuildJobBuilder from "./dockerfile-build-job-builder.service"; + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); + +describe('DockerfileBuildJobBuilder', () => { + it('builds a Dockerfile-based build job with queue init container and build annotations', async () => { + const job = await dockerfileBuildJobBuilder.buildJobDefinition({ + app: { + id: 'app-1', + projectId: 'project-1', + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + dockerfilePath: './apps/web/Dockerfile', + } as any, + buildName: 'build-1', + deploymentId: 'deployment-1', + latestRemoteGitHash: 'abc123', + latestRemoteGitCommitMessage: 'feat: test', + queuedAt: '123', + }); + + 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([ + 'dockerfile.v0', + 'filename=Dockerfile', + '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 new file mode 100644 index 00000000..a071ec43 --- /dev/null +++ b/src/server/services/build-job-builders/railpack-build-job-builder.service.ts @@ -0,0 +1,149 @@ +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 registryService, { BUILD_NAMESPACE } from "../registry.service"; + +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`; + +class RailpackBuildJobBuilder implements BuildJobBuilder { + + readonly buildMethod: AppBuildMethod = 'RAILPACK'; + + async buildJobDefinition(ctx: BuildJobBuilderContext): Promise { + const buildkitArgs = [ + "build", + "--local", + `context=${sourcePath}`, + "--local", + `dockerfile=${planPath}`, + "--frontend", + "gateway.v0", + "--opt", + `source=${RAILPACK_FRONTEND_IMAGE}`, + "--output", + `type=image,name=${registryService.createInternalContainerRegistryUrlForAppId(ctx.app.id)},push=true,registry.insecure=true` + ]; + + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: ctx.buildName, + namespace: BUILD_NAMESPACE, + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: ctx.app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: ctx.app.projectId, + [Constants.QS_ANNOTATION_GIT_COMMIT]: ctx.latestRemoteGitHash, + [Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]: ctx.latestRemoteGitCommitMessage.substring(0, 200), + [Constants.QS_ANNOTATION_DEPLOYMENT_ID]: ctx.deploymentId, + [Constants.QS_ANNOTATION_BUILD_QUEUED_AT]: ctx.queuedAt, + [Constants.QS_ANNOTATION_BUILD_METHOD]: this.buildMethod, + } + }, + spec: { + ttlSecondsAfterFinished: 86400, + template: { + metadata: { + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: ctx.app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: ctx.app.projectId, + [Constants.QS_ANNOTATION_GIT_COMMIT]: ctx.latestRemoteGitHash, + [Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]: ctx.latestRemoteGitCommitMessage.substring(0, 200), + [Constants.QS_ANNOTATION_DEPLOYMENT_ID]: ctx.deploymentId, + [Constants.QS_ANNOTATION_BUILD_METHOD]: this.buildMethod, + }, + }, + spec: { + hostUsers: false, + serviceAccountName: 'qs-build-watcher', + initContainers: [ + buildInitContainerService.getInitContainer(ctx.buildName, ctx.queuedAt), + this.getPreparedRailpackInitContainer(ctx), + ], + ...(ctx.nodeSelector ? { nodeSelector: ctx.nodeSelector } : {}), + containers: [ + { + name: ctx.buildName, + image: buildkitImage, + command: ["buildctl-daemonless.sh"], + args: buildkitArgs, + securityContext: { + privileged: true + }, + ...(ctx.resources ? { resources: ctx.resources } : {}), + volumeMounts: [{ name: sharedVolumeName, mountPath: sharedMountPath }], + }, + ], + restartPolicy: "Never", + volumes: [ + { + name: sharedVolumeName, + emptyDir: {}, + }, + ], + }, + }, + backoffLimit: 0, + }, + }; + } + + private getPreparedRailpackInitContainer(ctx: BuildJobBuilderContext): V1Container { + const gitUrl = this.getAuthenticatedGitUrl(ctx); + const script = [ + 'set -euo pipefail', + 'apt-get update', + 'apt-get install -y --no-install-recommends ca-certificates curl git', + '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}`, + 'echo "Prepared Railpack build plan:"', + `cat ${railpackInfoFile} || true`, + ].join('\n'); + + return { + name: 'railpack-prepare-init', + image: 'debian:bookworm-slim', + 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 }], + }; + } + + 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(); +export default 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 new file mode 100644 index 00000000..54886831 --- /dev/null +++ b/src/server/services/build-job-builders/railpack-build-job-builder.service.unit.spec.ts @@ -0,0 +1,43 @@ +import railpackBuildJobBuilder, { RAILPACK_FRONTEND_IMAGE } from "./railpack-build-job-builder.service"; + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); + +describe('RailpackBuildJobBuilder', () => { + it('builds a Railpack job with queue init, prepare init, shared volume, and frontend image', async () => { + const job = await railpackBuildJobBuilder.buildJobDefinition({ + app: { + id: 'app-1', + projectId: 'project-1', + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + } as any, + buildName: 'build-1', + deploymentId: 'deployment-1', + latestRemoteGitHash: 'abc123', + latestRemoteGitCommitMessage: 'feat: test', + queuedAt: '123', + }); + + const initContainers = job.spec?.template?.spec?.initContainers ?? []; + const buildContainer = job.spec?.template?.spec?.containers[0]!; + + expect(job.metadata?.annotations?.['qs-build-method']).toBe('RAILPACK'); + expect(job.spec?.template?.metadata?.annotations?.['qs-deplyoment-id']).toBe('deployment-1'); + expect(initContainers.map((container) => container.name)).toEqual([ + 'build-queue-init', + 'railpack-prepare-init', + ]); + expect(job.spec?.template?.spec?.volumes).toEqual([ + expect.objectContaining({ + name: 'railpack-workspace', + emptyDir: {}, + }), + ]); + expect(buildContainer.args).toEqual(expect.arrayContaining([ + 'gateway.v0', + `source=${RAILPACK_FRONTEND_IMAGE}`, + 'context=/workspace/source', + 'dockerfile=/workspace/plan', + ])); + }); +}); diff --git a/src/server/services/build.service.builders.unit.spec.ts b/src/server/services/build.service.builders.unit.spec.ts new file mode 100644 index 00000000..1f02e7ba --- /dev/null +++ b/src/server/services/build.service.builders.unit.spec.ts @@ -0,0 +1,107 @@ +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { + batch: { + createNamespacedJob: vi.fn(), + listNamespacedJob: vi.fn().mockResolvedValue({ body: { items: [] } }), + readNamespacedJobStatus: vi.fn(), + }, + core: { + listNamespacedPod: vi.fn(), + readNamespacedPod: vi.fn(), + }, + log: { + log: vi.fn(), + }, + }, +})); +vi.mock('@/server/adapter/db.client', () => ({ default: { client: { app: { findMany: vi.fn() } } } })); +vi.mock('@/server/services/namespace.service', () => ({ default: { createNamespaceIfNotExists: vi.fn() } })); +vi.mock('@/server/services/registry.service', () => ({ + __esModule: true, + BUILD_NAMESPACE: 'qs-build', + default: { + deployRegistry: vi.fn(), + doesImageExist: vi.fn().mockResolvedValue(false), + }, +})); +vi.mock('@/server/services/param.service', () => ({ + __esModule: true, + ParamService: { + REGISTRY_SOTRAGE_LOCATION: 'REGISTRY_SOTRAGE_LOCATION', + BUILD_NODE: 'BUILD_NODE', + BUILD_MEMORY_LIMIT: 'BUILD_MEMORY_LIMIT', + BUILD_MEMORY_RESERVATION: 'BUILD_MEMORY_RESERVATION', + BUILD_CPU_LIMIT: 'BUILD_CPU_LIMIT', + BUILD_CPU_RESERVATION: 'BUILD_CPU_RESERVATION', + }, + default: { + getString: vi.fn(async (key: string) => key === 'REGISTRY_SOTRAGE_LOCATION' ? 'registry-path' : undefined), + getNumber: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock('@/server/services/cluster.service', () => ({ + default: { + getNodeResourceUsage: vi.fn().mockResolvedValue([]), + getNodeInfo: vi.fn().mockResolvedValue([]), + }, +})); +vi.mock('@/server/services/build-job-builders/build-init-container.service', () => ({ + default: { + ensureRbacResources: vi.fn(), + }, +})); +vi.mock('@/server/services/git.service', () => ({ default: { openGitContext: vi.fn() } })); +vi.mock('@/server/services/pod.service', () => ({ default: {} })); +vi.mock('@/server/services/deployment-logs.service', () => ({ dlog: vi.fn() })); + +import buildService from '@/server/services/build.service'; +import gitService from '@/server/services/git.service'; +import dockerfileBuildJobBuilder from '@/server/services/build-job-builders/dockerfile-build-job-builder.service'; +import railpackBuildJobBuilder from '@/server/services/build-job-builders/railpack-build-job-builder.service'; + +describe('BuildService.buildApp builder selection', () => { + const dockerfileCheckSpy = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(dockerfileBuildJobBuilder, 'buildJobDefinition').mockResolvedValue({} as any); + vi.spyOn(railpackBuildJobBuilder, 'buildJobDefinition').mockResolvedValue({} as any); + vi.mocked(gitService.openGitContext).mockImplementation(async (_app, fn) => fn({ + checkIfDockerfileExists: dockerfileCheckSpy, + getLatestRemoteCommitHash: vi.fn().mockResolvedValue('abc123'), + getLatestRemoteCommitMessage: vi.fn().mockResolvedValue('feat: test'), + } as any)); + }); + + it('uses the Dockerfile builder for Dockerfile apps', async () => { + await buildService.buildApp('deployment-1', { + id: 'app-1', + projectId: 'project-1', + sourceType: 'GIT', + buildMethod: 'DOCKERFILE', + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + dockerfilePath: './Dockerfile', + } as any); + + expect(dockerfileCheckSpy).toHaveBeenCalled(); + expect(dockerfileBuildJobBuilder.buildJobDefinition).toHaveBeenCalled(); + expect(railpackBuildJobBuilder.buildJobDefinition).not.toHaveBeenCalled(); + }); + + it('uses the Railpack builder for Railpack apps', async () => { + await buildService.buildApp('deployment-1', { + id: 'app-1', + projectId: 'project-1', + sourceType: 'GIT', + buildMethod: 'RAILPACK', + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + dockerfilePath: './Dockerfile', + } as any); + + expect(dockerfileCheckSpy).not.toHaveBeenCalled(); + expect(railpackBuildJobBuilder.buildJobDefinition).toHaveBeenCalled(); + expect(dockerfileBuildJobBuilder.buildJobDefinition).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/services/build.service.ts b/src/server/services/build.service.ts index 063b62e1..58d5118b 100644 --- a/src/server/services/build.service.ts +++ b/src/server/services/build.service.ts @@ -1,45 +1,48 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import k3s from "../adapter/kubernetes-api.adapter"; -import { V1Job, V1JobStatus, V1ResourceRequirements } from "@kubernetes/client-node"; -import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; +import { AppBuildMethod } from "@/shared/model/app-source-info.model"; import { BuildJobModel } from "@/shared/model/build-job"; import { GlobalBuildJobModel } from "@/shared/model/global-build-job.model"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import dataAccess from "../adapter/db.client"; import { PodsInfoModel } from "@/shared/model/pods-info.model"; -import namespaceService from "./namespace.service"; +import { ServiceException } from "@/shared/model/service.exception.model"; import { Constants } from "../../shared/utils/constants"; -import gitService from "./git.service"; +import dataAccess from "../adapter/db.client"; +import k3s from "../adapter/kubernetes-api.adapter"; +import buildInitContainerService 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"; +import clusterService from "./cluster.service"; import { dlog } from "./deployment-logs.service"; -import podService from "./pod.service"; -import stream from "stream"; -import { PathUtils } from "../utils/path.utils"; -import registryService, { BUILD_NAMESPACE } from "./registry.service"; +import gitService from "./git.service"; +import namespaceService from "./namespace.service"; import paramService, { ParamService } from "./param.service"; -import clusterService from "./cluster.service"; -import buildInitContainerService from "./build-init-container.service"; - -const buildkitImage = "moby/buildkit:master"; +import registryService, { BUILD_NAMESPACE } from "./registry.service"; +import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; +import { V1JobStatus, V1ResourceRequirements } from "@kubernetes/client-node"; class BuildService { - async buildApp(deploymentId: string, app: AppExtendedModel, forceBuild: boolean = false): Promise<[string, string, string, boolean]> { await namespaceService.createNamespaceIfNotExists(BUILD_NAMESPACE); const registryLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION); await registryService.deployRegistry(registryLocation!); + const buildsForApp = await this.getBuildsForApp(app.id); - if (buildsForApp.some((job) => job.status === 'RUNNING')) { + if (buildsForApp.some((job) => job.status === 'RUNNING' || job.status === 'PENDING')) { throw new ServiceException("A build job is already running for this app."); } - dlog(deploymentId, `Initialized app build...`); - dlog(deploymentId, `Trying to clone repository...`); + const buildMethod = this.getBuildMethod(app); + await dlog(deploymentId, `Initialized app build...`); + await dlog(deploymentId, `Selected build method: ${buildMethod}`); + await dlog(deploymentId, `Trying to clone repository...`); - // Check if last build is already up to date with data in git repo const latestSuccessfulBuld = buildsForApp.find(x => x.status === 'SUCCEEDED'); const { latestRemoteGitHash, latestRemoteGitCommitMessage } = await gitService.openGitContext(app, async (ctx) => { - await ctx.checkIfDockerfileExists(); + if (buildMethod === 'DOCKERFILE') { + await ctx.checkIfDockerfileExists(); + } + const [hash, message] = await Promise.all([ ctx.getLatestRemoteCommitHash(), ctx.getLatestRemoteCommitMessage(), @@ -47,65 +50,76 @@ class BuildService { return { latestRemoteGitHash: hash, latestRemoteGitCommitMessage: message }; }); - dlog(deploymentId, `Cloned repository successfully`); - dlog(deploymentId, `Latest remote git hash: ${latestRemoteGitHash}`); + await dlog(deploymentId, `Cloned repository successfully`); + await dlog(deploymentId, `Latest remote git hash: ${latestRemoteGitHash}`); if (!forceBuild && latestSuccessfulBuld?.gitCommit && latestRemoteGitHash && - latestSuccessfulBuld?.gitCommit === latestRemoteGitHash) { - + latestSuccessfulBuld.gitCommit === latestRemoteGitHash) { if (await registryService.doesImageExist(app.id, 'latest')) { await dlog(deploymentId, `Latest build is already up to date with git repository, using container from last build.`); return [latestSuccessfulBuld.name, latestRemoteGitHash, latestRemoteGitCommitMessage, true]; - } else { - await dlog(deploymentId, `Docker Image for last build not found in internal registry, creating new build.`); } + + await dlog(deploymentId, `Docker Image for last build not found in internal registry, creating new build.`); } - return await this.createAndStartBuildJob(deploymentId, app, latestRemoteGitHash, latestRemoteGitCommitMessage); - } - private async createAndStartBuildJob(deploymentId: string, app: AppExtendedModel, latestRemoteGitHash: string, latestRemoteGitCommitMessage: string = ''): Promise<[string, string, string, boolean]> { + return this.createAndStartBuildJob(deploymentId, app, latestRemoteGitHash, latestRemoteGitCommitMessage); + } + private async createAndStartBuildJob( + deploymentId: string, + app: AppExtendedModel, + latestRemoteGitHash: string, + latestRemoteGitCommitMessage: string = '', + ): Promise<[string, string, string, boolean]> { const buildName = KubeObjectNameUtils.addRandomSuffix(KubeObjectNameUtils.toJobName(app.id)); + const buildMethod = this.getBuildMethod(app); + const builder = this.getBuilder(buildMethod); - dlog(deploymentId, `Creating build job with name: ${buildName}`); - + await dlog(deploymentId, `Creating build job with name: ${buildName}`); await buildInitContainerService.ensureRbacResources(); + + if (buildMethod === 'DOCKERFILE') { + await dlog(deploymentId, `Dockerfile path: ${app.dockerfilePath || './Dockerfile'}`); + } else { + await dlog(deploymentId, `Railpack build will run queue wait, prepare step, and BuildKit build in sequence.`); + } + const queuedAt = Date.now().toString(); - const initContainer = buildInitContainerService.getInitContainer(buildName, queuedAt); + const schedulingConfig = await this.getBuildSchedulingConfig(deploymentId); + const jobDefinition = await builder.buildJobDefinition({ + app, + buildName, + deploymentId, + latestRemoteGitHash, + latestRemoteGitCommitMessage, + queuedAt, + ...schedulingConfig, + }); - const contextPaths = PathUtils.splitPath(app.dockerfilePath); + await k3s.batch.createNamespacedJob(BUILD_NAMESPACE, jobDefinition); + await dlog(deploymentId, `Build job ${buildName} scheduled successfully`); - // Prepare Git URL with authentication if needed - let gitContextUrl = `${app.gitUrl!}#refs/heads/${app.gitBranch}${contextPaths.folderPath ? ':' + contextPaths.folderPath : ''}`; - if (app.gitUsername && app.gitToken) { - const authenticatedGitUrl = app.gitUrl!.replace('https://', `https://${app.gitUsername}:${app.gitToken}@`); - gitContextUrl = `${authenticatedGitUrl}#refs/heads/${app.gitBranch}${contextPaths.folderPath ? ':' + contextPaths.folderPath : ''}`; - } + return [buildName, latestRemoteGitHash, latestRemoteGitCommitMessage, false]; + } - // BuildKit arguments for buildctl-daemonless.sh - const buildkitArgs = [ - "build", - "--frontend", - "dockerfile.v0", - "--opt", - `filename=${contextPaths.filePath}`, - "--opt", - `context=${gitContextUrl}`, - "--output", - `type=image,name=${registryService.createInternalContainerRegistryUrlForAppId(app.id)},push=true,registry.insecure=true` - ]; - - dlog(deploymentId, `Dockerfile context path: ${contextPaths.folderPath ?? 'root directory of Git Repository'}. Dockerfile name: ${contextPaths.filePath}`); - - // Read global build settings - const buildNode = await paramService.getString(ParamService.BUILD_NODE); + private getBuildMethod(app: AppExtendedModel): AppBuildMethod { + return app.buildMethod === 'DOCKERFILE' ? 'DOCKERFILE' : 'RAILPACK'; + } - // Determine node selector and resource limits based on build node setting + private getBuilder(buildMethod: AppBuildMethod): BuildJobBuilder { + return buildMethod === 'DOCKERFILE' ? dockerfileBuildJobBuilder : railpackBuildJobBuilder; + } + + private async getBuildSchedulingConfig(deploymentId: string): Promise<{ + nodeSelector?: Record; + resources?: V1ResourceRequirements; + }> { + const buildNode = await paramService.getString(ParamService.BUILD_NODE); let nodeSelector: Record | undefined; let resources: V1ResourceRequirements | undefined; if (buildNode === Constants.BUILD_NODE_K3S_NATIVE_VALUE) { - // k3s native: let k3s schedule based on resource limits, no nodeSelector const [memoryLimit, memoryReservation, cpuLimit, cpuReservation] = await Promise.all([ paramService.getNumber(ParamService.BUILD_MEMORY_LIMIT), paramService.getNumber(ParamService.BUILD_MEMORY_RESERVATION), @@ -130,125 +144,49 @@ class BuildService { } : {}), }; } + const resourceLimitsString = [ memoryLimit ? `memory limit: ${memoryLimit}M` : null, memoryReservation ? `memory reservation: ${memoryReservation}M` : null, cpuLimit ? `CPU limit: ${cpuLimit}m` : null, cpuReservation ? `CPU reservation: ${cpuReservation}m` : null, - ] - dlog(deploymentId, `Build scheduling: k3s native - ${resourceLimitsString.filter(s => !!s).join(', ') || 'no resource limits or reservations configured'}`); - } else if (buildNode) { - // specific node pinned + ]; + await dlog(deploymentId, `Build scheduling: k3s native - ${resourceLimitsString.filter(Boolean).join(', ') || 'no resource limits or reservations configured'}`); + return { resources }; + } + + if (buildNode) { const nodes = await clusterService.getNodeInfo(); const targetNode = nodes.find(n => n.name === buildNode); if (!targetNode || !targetNode.schedulable) { - throw new ServiceException( - `Configured build node '${buildNode}' is not schedulable. Please update build settings.` - ); + throw new ServiceException(`Configured build node '${buildNode}' is not schedulable. Please update build settings.`); } + nodeSelector = { 'kubernetes.io/hostname': buildNode }; - dlog(deploymentId, `Build node pinned to: ${buildNode}`); - } else { - // auto: pick node with most available RAM - try { - const [nodeResources, nodeInfos] = await Promise.all([ - clusterService.getNodeResourceUsage(), - clusterService.getNodeInfo(), - ]); - const schedulableNames = new Set(nodeInfos.filter(n => n.schedulable).map(n => n.name)); - const bestNode = nodeResources - .filter(n => schedulableNames.has(n.name)) - .sort((a, b) => (b.ramCapacity - b.ramUsage) - (a.ramCapacity - a.ramUsage))[0]; - if (bestNode) { - nodeSelector = { 'kubernetes.io/hostname': bestNode.name }; - dlog(deploymentId, `Auto-selected build node with most available resources: ${bestNode.name}`); - } - } catch { - dlog(deploymentId, `Could not determine best build node, scheduling on any available node.`); - } + await dlog(deploymentId, `Build node pinned to: ${buildNode}`); + return { nodeSelector }; } - const jobDefinition: V1Job = { - apiVersion: "batch/v1", - kind: "Job", - metadata: { - name: buildName, - namespace: BUILD_NAMESPACE, - annotations: { - [Constants.QS_ANNOTATION_APP_ID]: app.id, - [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, - [Constants.QS_ANNOTATION_GIT_COMMIT]: latestRemoteGitHash, - [Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]: latestRemoteGitCommitMessage.substring(0, 200), // truncate to avoid exceeding 256 KiB size limits of annotations object. - [Constants.QS_ANNOTATION_DEPLOYMENT_ID]: deploymentId, - [Constants.QS_ANNOTATION_BUILD_QUEUED_AT]: queuedAt, - } - }, - spec: { - ttlSecondsAfterFinished: 86400, // 1 day - template: { - spec: { - // Depends on feature gate UserNamespacesSupport (available in k8s 1.25+) - hostUsers: false, - serviceAccountName: 'qs-build-watcher', - initContainers: [initContainer], - ...(nodeSelector ? { nodeSelector } : {}), - containers: [ - { - name: buildName, - image: buildkitImage, - command: ["buildctl-daemonless.sh"], - args: buildkitArgs, - securityContext: { - privileged: true - }, - ...(resources ? { resources } : {}), - }, - ], - restartPolicy: "Never", - - }, - }, - backoffLimit: 0, - }, - }; - await k3s.batch.createNamespacedJob(BUILD_NAMESPACE, jobDefinition); - - await dlog(deploymentId, `Build job ${buildName} scheduled successfully`); - - return [buildName, latestRemoteGitHash, latestRemoteGitCommitMessage, false]; - } - - async logBuildOutput(deploymentId: string, buildName: string) { - - const pod = await this.getPodForJob(buildName); - await podService.waitUntilPodIsRunningFailedOrSucceded(BUILD_NAMESPACE, pod.podName); - - const logStream = new stream.PassThrough(); - - const k3sStreamRequest = await k3s.log.log(BUILD_NAMESPACE, pod.podName, pod.containerName, logStream, { - follow: true, - tailLines: undefined, - timestamps: true, - pretty: false, - previous: false - }); - - logStream.on('data', async (chunk) => { - await dlog(deploymentId, chunk.toString(), false, false); - }); - - logStream.on('error', async (error) => { - console.error("Error in build log stream for deployment " + deploymentId, error); - await dlog(deploymentId, '[ERROR] An unexpected error occurred while streaming logs.'); - }); + try { + const [nodeResources, nodeInfos] = await Promise.all([ + clusterService.getNodeResourceUsage(), + clusterService.getNodeInfo(), + ]); + const schedulableNames = new Set(nodeInfos.filter(n => n.schedulable).map(n => n.name)); + const bestNode = nodeResources + .filter(n => schedulableNames.has(n.name)) + .sort((a, b) => (b.ramCapacity - b.ramUsage) - (a.ramCapacity - a.ramUsage))[0]; + if (bestNode) { + nodeSelector = { 'kubernetes.io/hostname': bestNode.name }; + await dlog(deploymentId, `Auto-selected build node with most available resources: ${bestNode.name}`); + } + } catch { + await dlog(deploymentId, `Could not determine best build node, scheduling on any available node.`); + } - logStream.on('end', async () => { - console.log(`[END] Log stream ended for build process: ${buildName}`); - await dlog(deploymentId, `[END] Log stream ended for build process: ${buildName}`); - }); + return { nodeSelector }; } - async deleteAllBuildsOfApp(appId: string) { const jobNamePrefix = KubeObjectNameUtils.toJobName(appId); const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE); @@ -262,7 +200,7 @@ class BuildService { const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE); const jobsToDelete = jobs.body.items.filter((job) => { const status = this.getJobStatusString(job.status); - return !status || status !== 'RUNNING'; + return status !== 'RUNNING' && status !== 'PENDING'; }); for (const job of jobsToDelete) { await this.deleteBuild(job.metadata?.name!); @@ -303,16 +241,15 @@ class BuildService { const jobNamePrefix = KubeObjectNameUtils.toJobName(appId); const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE); const jobsOfBuild = jobs.body.items.filter((job) => job.metadata?.name?.startsWith(jobNamePrefix)); - const builds = jobsOfBuild.map((job) => { - return { - name: job.metadata?.name, - startTime: job.status?.startTime, - status: this.getJobStatusString(job.status), - gitCommit: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT], - gitCommitMessage: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE], - deploymentId: job.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID], - } as BuildJobModel; - }); + const builds = jobsOfBuild.map((job) => ({ + name: job.metadata?.name, + startTime: job.status?.startTime, + status: this.getJobStatusString(job.status), + gitCommit: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT], + gitCommitMessage: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE], + deploymentId: job.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID], + buildMethod: job.metadata?.annotations?.[Constants.QS_ANNOTATION_BUILD_METHOD] as AppBuildMethod | undefined, + } as BuildJobModel)); builds.sort((a, b) => { if (a.startTime && b.startTime) { return new Date(b.startTime).getTime() - new Date(a.startTime).getTime(); @@ -322,25 +259,10 @@ class BuildService { return builds; } - - async getPodForJob(jobName: string) { - const res = await k3s.core.listNamespacedPod(BUILD_NAMESPACE, undefined, undefined, undefined, undefined, `job-name=${jobName}`); - const jobs = res.body.items; - if (jobs.length === 0) { - throw new ServiceException(`No pod found for job ${jobName}`); - } - const pod = jobs[0]; - return { - podName: pod.metadata?.name!, - containerName: pod.spec?.containers?.[0].name! - } as PodsInfoModel; - } - async getJobStatus(buildName: string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED' | 'PENDING'> { try { const response = await k3s.batch.readNamespacedJobStatus(buildName, BUILD_NAMESPACE); - const status = response.body.status; - return this.getJobStatusString(status); + return this.getJobStatusString(response.body.status); } catch (err) { console.error(err); } @@ -351,7 +273,7 @@ class BuildService { if (!status) { return 'UNKNOWN'; } - if (status.ready ?? 0 > 0) { + if ((status.ready ?? 0) > 0) { return 'RUNNING'; } if ((status.failed ?? 0) > 0) { @@ -363,7 +285,7 @@ class BuildService { if ((status.terminating ?? 0) > 0) { return 'UNKNOWN'; } - if (!!status.completionTime) { + if (status.completionTime) { return 'SUCCEEDED'; } if ((status.active ?? 0) > 0) { @@ -379,13 +301,14 @@ class BuildService { .map((job) => job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID]) .filter((id): id is string => !!id) )); + const apps = await dataAccess.client.app.findMany({ where: { id: { in: appIds } }, include: { project: true }, }); const appMap = new Map(apps.map((a) => [a.id, a])); - const builds: GlobalBuildJobModel[] = jobs.body.items + return jobs.body.items .map((job) => { const appId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID]; const projectId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_PROJECT_ID]; @@ -402,11 +325,10 @@ class BuildService { appName: app?.name ?? appId ?? 'Unknown', projectName: app?.project?.name ?? projectId ?? 'Unknown', completionTime: job.status?.completionTime ?? undefined, + buildMethod: job.metadata?.annotations?.[Constants.QS_ANNOTATION_BUILD_METHOD] as AppBuildMethod | undefined, } as GlobalBuildJobModel; }) .sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); - - return builds; } } diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index 5db6e7bc..3d76ab05 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -20,6 +20,7 @@ import fileBrowserService from "./file-browser-service"; import podService from "./pod.service"; import networkPolicyService from "./network-policy.service"; import { z } from "zod"; +import { AppBuildMethod } from "@/shared/model/app-source-info.model"; class DeploymentService { @@ -70,7 +71,14 @@ class DeploymentService { } } - async createDeployment(deploymentId: string, app: AppExtendedModel, buildJobName?: string, gitCommitHash?: string, gitCommitMessage?: string) { + async createDeployment( + deploymentId: string, + app: AppExtendedModel, + buildJobName?: string, + gitCommitHash?: string, + gitCommitMessage?: string, + buildMethod?: AppBuildMethod, + ) { await this.validateDeployment(app); dlog(deploymentId, `Shutting down FileBrowsers (if active)`); @@ -152,6 +160,9 @@ class DeploymentService { if (buildJobName) { body.spec!.template!.metadata!.annotations!.buildJobName = buildJobName; // add buildJobName to deployment } + if (buildMethod) { + body.spec!.template!.metadata!.annotations![Constants.QS_ANNOTATION_BUILD_METHOD] = buildMethod; + } if (gitCommitHash) { body.spec!.template!.metadata!.annotations![Constants.QS_ANNOTATION_GIT_COMMIT] = gitCommitHash; @@ -326,10 +337,11 @@ class DeploymentService { replicasetName: undefined, createdAt: build.startTime!, buildJobName: build.name!, - status: this.mapBuildStatusToDeploymentStatus(build.status), - gitCommit: build.gitCommit, - gitCommitMessage: build.gitCommitMessage, - deploymentId: build.deploymentId + status: this.mapBuildStatusToDeploymentStatus(build.status), + gitCommit: build.gitCommit, + gitCommitMessage: build.gitCommitMessage, + deploymentId: build.deploymentId, + buildMethod: build.buildMethod, } }); replicasetRevisions.push(...runningOrFailedBuilds); @@ -366,7 +378,8 @@ class DeploymentService { gitCommit: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT], gitCommitMessage: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE], status: status, - deploymentId: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID]! + deploymentId: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID]!, + buildMethod: rs.spec?.template?.metadata?.annotations?.[Constants.QS_ANNOTATION_BUILD_METHOD] as AppBuildMethod | undefined, } }); return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true); diff --git a/src/server/services/standalone-services/build-pod-log-watch.service.ts b/src/server/services/standalone-services/build-pod-log-watch.service.ts new file mode 100644 index 00000000..ea3227ca --- /dev/null +++ b/src/server/services/standalone-services/build-pod-log-watch.service.ts @@ -0,0 +1,162 @@ +import * as k8s from '@kubernetes/client-node'; +import { V1ContainerStatus, V1Pod } from '@kubernetes/client-node'; +import { Constants } from '@/shared/utils/constants'; +import k3s from '../../adapter/kubernetes-api.adapter'; +import { dlog } from '../deployment-logs.service'; +import { BUILD_NAMESPACE } from '../registry.service'; +import stream from 'stream'; + +declare global { + var buildPodLogWatchServiceInstance: BuildPodLogWatchService | undefined; +} + +class BuildPodLogWatchService { + private isWatchRunning = false; + private processedContainerKeys = new Set(); + private activeContainerKeys = new Set(); + + async startWatch() { + if (this.isWatchRunning) { + console.log('[BuildPodLogWatch] Watch already running, skipping start.'); + return; + } + + this.isWatchRunning = true; + console.log('[BuildPodLogWatch] Starting build pod log watch...'); + + const watch = new k8s.Watch(k3s.getKubeConfig()); + await watch.watch( + `/api/v1/namespaces/${BUILD_NAMESPACE}/pods`, + {}, + async (type: string, apiObj: unknown) => { + try { + await this.handlePodEvent(type, apiObj as V1Pod); + } catch (error) { + console.error('[BuildPodLogWatch] Error handling pod event:', error); + } + }, + (err: unknown) => { + if (err) { + console.error('[BuildPodLogWatch] Watch error:', err); + } + console.log('[BuildPodLogWatch] Watch ended, restarting in 5s...'); + this.isWatchRunning = false; + setTimeout(() => { + this.startWatch().catch((error) => { + console.error('[BuildPodLogWatch] Failed to restart watch:', error); + }); + }, 5000); + }, + ); + } + + private async handlePodEvent(type: string, pod: V1Pod) { + if (type === 'DELETED') { + return; + } + await this.captureLogsForPod(pod); + } + + private async captureLogsForPod(pod: V1Pod) { + const deploymentId = pod.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID]; + if (!deploymentId) { + return; + } + + const podName = pod.metadata?.name; + if (!podName) { + return; + } + + const containersOfThisPod = this.getReadableContainersInOrder(pod); + for (const container of containersOfThisPod) { + const containerKey = this.createContainerKey(pod, container.name); + if (this.processedContainerKeys.has(containerKey) || this.activeContainerKeys.has(containerKey)) { + continue; + } + + void this.streamContainerLogs(deploymentId, podName, container.name, containerKey, !!container.status?.state?.running); + } + } + + private getReadableContainersInOrder(pod: V1Pod): Array<{ name: string; status?: V1ContainerStatus }> { + const initStatuses = pod.status?.initContainerStatuses ?? []; + const containerStatuses = pod.status?.containerStatuses ?? []; + + return [ + ...(pod.spec?.initContainers ?? []).filter((container) => { + // th elogs of the container wich waits for the build to start does not contain useful information, so we skip it. + return !container.name.toLowerCase().includes(Constants.QS_BUILD_INIT_CONTAINER_NAME.toLowerCase()); + }).map((container) => ({ + name: container.name!, + status: initStatuses.find((status) => status.name === container.name), + })), + ...(pod.spec?.containers ?? []).map((container) => ({ + name: container.name!, + status: containerStatuses.find((status) => status.name === container.name), + })), + ].filter((container) => !!container.status?.state?.running || !!container.status?.state?.terminated); + } + + private createContainerKey(pod: V1Pod, containerName: string) { + return `${pod.metadata?.uid ?? pod.metadata?.name}:${containerName}`; + } + + private async streamContainerLogs( + deploymentId: string, + podName: string, + containerName: string, + containerKey: string, + follow: boolean, + ) { + this.activeContainerKeys.add(containerKey); + await dlog(deploymentId, `[INFO] Streaming logs from pod "${podName}" container "${containerName}"`); + + const logStream = new stream.PassThrough(); + let logRequest: { abort?: () => void } | undefined; + try { + logRequest = await k3s.log.log(BUILD_NAMESPACE, podName, containerName, logStream, { + follow, + tailLines: undefined, + timestamps: true, + pretty: false, + previous: false, + }); + } catch (error) { + this.activeContainerKeys.delete(containerKey); + await dlog(deploymentId, `[ERROR] Failed to start log stream for pod "${podName}" container "${containerName}".`); + console.error(`[BuildPodLogWatch] Failed to start log stream for ${podName}/${containerName}:`, error); + return; + } + + let settled = false; + const finalize = async (error?: unknown) => { + if (settled) { + return; + } + settled = true; + this.activeContainerKeys.delete(containerKey); + this.processedContainerKeys.add(containerKey); + logRequest?.abort?.(); + + if (error) { + console.error(`[BuildPodLogWatch] Error while streaming ${podName}/${containerName}:`, error); + await dlog(deploymentId, `[ERROR] An unexpected error occurred while streaming logs from pod "${podName}" container "${containerName}".`); + } + }; + + logStream.on('data', (chunk) => { + void dlog(deploymentId, chunk.toString(), false, false); + }); + logStream.on('error', (error) => { + void finalize(error); + }); + logStream.on('end', () => { + void finalize(); + }); + } +} + +const buildPodLogWatchService = globalThis.buildPodLogWatchServiceInstance ?? new BuildPodLogWatchService(); +globalThis.buildPodLogWatchServiceInstance = buildPodLogWatchService; +export default buildPodLogWatchService; diff --git a/src/server/services/standalone-services/build-pod-log-watch.service.unit.spec.ts b/src/server/services/standalone-services/build-pod-log-watch.service.unit.spec.ts new file mode 100644 index 00000000..aa1c5063 --- /dev/null +++ b/src/server/services/standalone-services/build-pod-log-watch.service.unit.spec.ts @@ -0,0 +1,110 @@ +vi.mock('@kubernetes/client-node', async () => { + const actual = await vi.importActual('@kubernetes/client-node'); + class WatchMock { + watch = vi.fn().mockResolvedValue({ abort: vi.fn() }); + } + return { + ...actual, + Watch: WatchMock, + }; +}); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { + getKubeConfig: vi.fn(), + core: { + listNamespacedPod: vi.fn(), + }, + log: { + log: vi.fn(), + }, + }, +})); +vi.mock('@/server/services/deployment-logs.service', () => ({ + dlog: vi.fn(), +})); +vi.mock('@/server/services/registry.service', () => ({ + BUILD_NAMESPACE: 'qs-build', +})); + +import k3s from '@/server/adapter/kubernetes-api.adapter'; +import { dlog } from '@/server/services/deployment-logs.service'; +import buildPodLogWatchService from '@/server/services/standalone-services/build-pod-log-watch.service'; +import stream from 'stream'; + +const flushPromises = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +describe('BuildPodLogWatchService', () => { + beforeEach(() => { + vi.clearAllMocks(); + (buildPodLogWatchService as any).processedContainerKeys.clear(); + (buildPodLogWatchService as any).activeContainerKeys.clear(); + (buildPodLogWatchService as any).isWatchRunning = false; + (buildPodLogWatchService as any).watchRequest = null; + }); + + it('ignores pods without deployment annotation', async () => { + vi.mocked(k3s.core.listNamespacedPod).mockResolvedValue({ + body: { + items: [{ + metadata: { + uid: 'pod-uid-1', + name: 'build-pod-1', + }, + spec: { + containers: [{ name: 'build-1' }], + }, + status: { + containerStatuses: [ + { name: 'build-1', state: { running: { startedAt: new Date().toISOString() } } }, + ], + }, + }], + }, + } as any); + + await buildPodLogWatchService.startWatch(); + await flushPromises(); + + expect(vi.mocked(k3s.log.log)).not.toHaveBeenCalled(); + }); + + it('does not start duplicate streams for the same pod container', async () => { + const pod = { + metadata: { + uid: 'pod-uid-1', + name: 'build-pod-1', + annotations: { + 'qs-deplyoment-id': 'deployment-1', + }, + }, + spec: { + containers: [{ name: 'build-1' }], + }, + status: { + containerStatuses: [ + { name: 'build-1', state: { running: { startedAt: new Date().toISOString() } } }, + ], + }, + }; + + let endStream: (() => void) | undefined; + vi.mocked(k3s.core.listNamespacedPod).mockResolvedValue({ body: { items: [pod] } } as any); + vi.mocked(k3s.log.log).mockImplementation(async (_ns, _podName, _containerName, logStream) => { + const writable = logStream as stream.PassThrough; + endStream = () => writable.end(); + return { abort: vi.fn() } as any; + }); + + await buildPodLogWatchService.startWatch(); + await (buildPodLogWatchService as any).captureLogsForPod(pod); + await flushPromises(); + + expect(vi.mocked(k3s.log.log)).toHaveBeenCalledTimes(1); + endStream?.(); + await flushPromises(); + }); +}); diff --git a/src/server/services/standalone-services/build-watch.service.ts b/src/server/services/standalone-services/build-watch.service.ts index dc81ec12..d0697c5f 100644 --- a/src/server/services/standalone-services/build-watch.service.ts +++ b/src/server/services/standalone-services/build-watch.service.ts @@ -7,6 +7,7 @@ import deploymentService from '../deployment.service'; import appService from '../app.service'; import { dlog } from '../deployment-logs.service'; import { BUILD_NAMESPACE } from '../registry.service'; +import { AppBuildMethod } from '@/shared/model/app-source-info.model'; declare global { var buildWatchServiceInstance: BuildWatchService | undefined; @@ -15,7 +16,6 @@ declare global { class BuildWatchService { private isWatchRunning = false; private processedJobs = new Set(); - private loggingStartedJobs = new Set(); async startWatch() { if (this.isWatchRunning) { @@ -66,11 +66,6 @@ class BuildWatchService { continue; } - if (status === 'RUNNING') { - this.startLogCaptureIfNeeded(job); - continue; - } - if (status === 'SUCCEEDED') { // Check if deployment already reflects this build via git commit comparison const appId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID]; @@ -120,8 +115,6 @@ class BuildWatchService { } else if (status === 'FAILED') { this.processedJobs.add(jobName); await this.handleFailed(job); - } else if (status === 'RUNNING') { - this.startLogCaptureIfNeeded(job); } } @@ -131,6 +124,7 @@ class BuildWatchService { const gitCommitHash = job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT]; const gitCommitMessage = job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE]; const buildJobName = job.metadata?.name; + const buildMethod = job.metadata?.annotations?.[Constants.QS_ANNOTATION_BUILD_METHOD] as AppBuildMethod | undefined; if (!deploymentId || !appId || !buildJobName) { console.error('[BuildWatch] handleSucceeded: missing required annotations on job', job.metadata?.name); @@ -144,30 +138,20 @@ class BuildWatchService { await dlog(deploymentId, `*************************************`); await dlog(deploymentId, `Starting deployment with output from build "${buildJobName}"`); const app = await appService.getExtendedById(appId, false); - await deploymentService.createDeployment(deploymentId, app, buildJobName, gitCommitHash, gitCommitMessage); + await deploymentService.createDeployment( + deploymentId, + app, + buildJobName, + gitCommitHash, + gitCommitMessage, + buildMethod ?? (app.buildMethod === 'DOCKERFILE' ? 'DOCKERFILE' : 'RAILPACK'), + ); } catch (e) { console.error(`[BuildWatch] Error triggering deployment for app ${appId}:`, e); if (deploymentId) { await dlog(deploymentId, `[ERROR] Deployment failed after build: ${e}`); } } - - if (buildJobName && this.loggingStartedJobs.has(buildJobName)) { - this.loggingStartedJobs.delete(buildJobName); - } - } - - private startLogCaptureIfNeeded(job: V1Job) { - const jobName = job.metadata?.name; - const deploymentId = job.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID]; - if (!jobName || !deploymentId || this.loggingStartedJobs.has(jobName)) { return; } - - this.loggingStartedJobs.add(jobName); - console.log(`[BuildWatch] Starting log capture for build job ${jobName}`); - buildService.logBuildOutput(deploymentId, jobName).catch((err) => { - dlog(deploymentId, `An error occurred while loading build logs: ${err instanceof Error ? err.message : JSON.stringify(err)}`); - console.error(`Error while streaming build logs for build ${jobName}:`, err); - }); } private async handleFailed(job: V1Job) { @@ -179,10 +163,6 @@ class BuildWatchService { await dlog(deploymentId, `*********************`); await dlog(deploymentId, ` ⚠ Build job failed. `); await dlog(deploymentId, `*********************`); - - if (buildJobName && this.loggingStartedJobs.has(buildJobName)) { - this.loggingStartedJobs.delete(buildJobName); - } } } diff --git a/src/server/services/standalone-services/build-watch.service.unit.spec.ts b/src/server/services/standalone-services/build-watch.service.unit.spec.ts new file mode 100644 index 00000000..4d54c346 --- /dev/null +++ b/src/server/services/standalone-services/build-watch.service.unit.spec.ts @@ -0,0 +1,102 @@ +vi.mock('@kubernetes/client-node', async () => { + const actual = await vi.importActual('@kubernetes/client-node'); + class WatchMock { + watch = vi.fn().mockResolvedValue({ abort: vi.fn() }); + } + return { + ...actual, + Watch: WatchMock, + }; +}); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { + getKubeConfig: vi.fn(), + batch: { + listNamespacedJob: vi.fn().mockResolvedValue({ body: { items: [] } }), + }, + }, +})); +vi.mock('@/server/services/build.service', () => ({ + default: { + getJobStatusString: vi.fn(), + }, +})); +vi.mock('@/server/services/deployment.service', () => ({ + default: { + getDeployment: vi.fn(), + createDeployment: vi.fn(), + }, +})); +vi.mock('@/server/services/app.service', () => ({ + default: { + getExtendedById: vi.fn(), + }, +})); +vi.mock('@/server/services/deployment-logs.service', () => ({ + dlog: vi.fn(), +})); +vi.mock('@/server/services/registry.service', () => ({ + BUILD_NAMESPACE: 'qs-build', +})); + +import buildService from '@/server/services/build.service'; +import buildWatchService from '@/server/services/standalone-services/build-watch.service'; +import deploymentService from '@/server/services/deployment.service'; +import appService from '@/server/services/app.service'; + +describe('BuildWatchService', () => { + beforeEach(() => { + vi.clearAllMocks(); + (buildWatchService as any).processedJobs.clear(); + }); + + it('ignores pending jobs and does not trigger deployment work', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('PENDING'); + + await (buildWatchService as any).handleJobEvent({ + metadata: { + name: 'build-1', + annotations: { + 'qs-deplyoment-id': 'deployment-1', + }, + }, + }); + + expect(deploymentService.createDeployment).not.toHaveBeenCalled(); + }); + + it('logs failed jobs without triggering deployment', async () => { + await (buildWatchService as any).handleFailed({ + metadata: { + name: 'build-1', + annotations: { + 'qs-deplyoment-id': 'deployment-1', + }, + }, + }); + + expect(deploymentService.createDeployment).not.toHaveBeenCalled(); + }); + + it('triggers deployment for succeeded jobs', async () => { + vi.mocked(appService.getExtendedById).mockResolvedValue({ + buildMethod: 'RAILPACK', + } as any); + + await (buildWatchService as any).handleSucceeded({ + metadata: { + name: 'build-1', + annotations: { + 'qs-deplyoment-id': 'deployment-1', + 'qs-app-id': 'app-1', + 'qs-git-commit': 'abc123', + 'qs-git-commit-message': 'feat: test', + 'qs-build-method': 'RAILPACK', + }, + }, + }); + + expect(deploymentService.createDeployment).toHaveBeenCalled(); + }); +}); diff --git a/src/shared/model/app-source-info.model.ts b/src/shared/model/app-source-info.model.ts index 0bb83064..e3648ee3 100644 --- a/src/shared/model/app-source-info.model.ts +++ b/src/shared/model/app-source-info.model.ts @@ -2,6 +2,8 @@ import { z } from "zod"; export const appSourceTypeZodModel = z.enum(["GIT", "CONTAINER"]); export const appTypeZodModel = z.enum(["APP", "POSTGRES", "MYSQL", "MARIADB", "MONGODB", "REDIS"]); +export const appBuildMethodZodModel = z.enum(["RAILPACK", "DOCKERFILE"]); +export type AppBuildMethod = z.infer; const gitHttpsUrlRegex = /^https:\/\/[^\s/]+(?::\d+)?(\/[^\s]*)+$/; const gitHubGitLabDotGitRegex = /^https:\/\/(github\.com|gitlab\.com)\//; @@ -17,7 +19,8 @@ export const appSourceInfoGitZodModel = z.object({ gitBranch: z.string().trim(), gitUsername: z.string().trim().nullish(), gitToken: z.string().trim().nullish(), - dockerfilePath: z.string().trim(), + buildMethod: appBuildMethodZodModel.default("RAILPACK"), + dockerfilePath: z.string().trim().nullish(), }); export type AppSourceInfoGitModel = z.infer; @@ -30,6 +33,7 @@ export type AppSourceInfoContainerModel = z.infer { + if (val.sourceType === 'GIT' && val.buildMethod === 'DOCKERFILE' && !val.dockerfilePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['dockerfilePath'], + message: 'Path to Dockerfile is required when using the Dockerfile build method.', + }); + } }); export type AppSourceInfoInputModel = z.infer; - diff --git a/src/shared/model/app-source-info.model.unit.spec.ts b/src/shared/model/app-source-info.model.unit.spec.ts new file mode 100644 index 00000000..8934c5e5 --- /dev/null +++ b/src/shared/model/app-source-info.model.unit.spec.ts @@ -0,0 +1,40 @@ +import { appSourceInfoGitZodModel, appSourceInfoInputZodModel } from "./app-source-info.model"; + +describe('appSourceInfoGitZodModel', () => { + const baseInput = { + gitUrl: 'https://github.com/example/repo.git', + gitBranch: 'main', + gitUsername: undefined, + gitToken: undefined, + }; + + it('allows Railpack builds without a dockerfile path', () => { + const result = appSourceInfoGitZodModel.safeParse({ + ...baseInput, + buildMethod: 'RAILPACK', + }); + + expect(result.success).toBe(true); + }); + + it('requires a dockerfile path for Dockerfile builds', () => { + const result = appSourceInfoInputZodModel.safeParse({ + ...baseInput, + sourceType: 'GIT', + buildMethod: 'DOCKERFILE', + dockerfilePath: '', + }); + + expect(result.success).toBe(false); + }); + + it('accepts a dockerfile path for Dockerfile builds', () => { + const result = appSourceInfoGitZodModel.safeParse({ + ...baseInput, + buildMethod: 'DOCKERFILE', + dockerfilePath: './Dockerfile', + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/src/shared/model/app-template.model.ts b/src/shared/model/app-template.model.ts index 63cda266..7394ab25 100644 --- a/src/shared/model/app-template.model.ts +++ b/src/shared/model/app-template.model.ts @@ -5,6 +5,7 @@ import { appVolumeTypeZodModel, storageClassNameZodModel } from "./volume-edit.m const appModelWithRelations = z.lazy(() => AppModel.extend({ projectId: z.undefined(), + buildMethod: z.undefined(), dockerfilePath: z.undefined(), appType: appTypeZodModel, sourceType: appSourceTypeZodModel, diff --git a/src/shared/model/backup-volume-edit.model.ts b/src/shared/model/backup-volume-edit.model.ts index cbaefd87..0a216f3e 100644 --- a/src/shared/model/backup-volume-edit.model.ts +++ b/src/shared/model/backup-volume-edit.model.ts @@ -5,7 +5,7 @@ export const volumeBackupEditZodModel = z.object({ id: z.string().nullish(), volumeId: z.string(), targetId: z.string(), - cron: z.string().trim().regex(/(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})/), + cron: z.string().trim().regex(/(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\*(\/|-)\d+)|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})/), //cron: z.string().trim().min(1), retention: stringToNumber, useDatabaseBackup: z.boolean().optional(), diff --git a/src/shared/model/build-job.ts b/src/shared/model/build-job.ts index 624af99f..0e9f93d8 100644 --- a/src/shared/model/build-job.ts +++ b/src/shared/model/build-job.ts @@ -1,5 +1,6 @@ import { GitCommit } from "lucide-react"; import { z } from "zod"; +import { appBuildMethodZodModel } from "./app-source-info.model"; export const buildJobStatusEnumZod = z.union([z.literal('UNKNOWN'), z.literal('RUNNING'), z.literal('FAILED'), z.literal('SUCCEEDED'), z.literal('PENDING')]); @@ -10,9 +11,9 @@ export const buildJobSchemaZod = z.object({ gitCommit: z.string(), gitCommitMessage: z.string().optional(), deploymentId: z.string(), + buildMethod: appBuildMethodZodModel.optional(), }); export type BuildJobModel = z.infer; export type BuildJobStatus = z.infer; - diff --git a/src/shared/model/deployment-info.model.ts b/src/shared/model/deployment-info.model.ts index 2032413e..01308517 100644 --- a/src/shared/model/deployment-info.model.ts +++ b/src/shared/model/deployment-info.model.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { appBuildMethodZodModel } from "./app-source-info.model"; export const deploymentStatusEnumZod = z.union([ z.literal('UNKNOWN'), @@ -19,9 +20,9 @@ export const deploymentInfoZodModel = z.object({ gitCommit: z.string().optional(), gitCommitMessage: z.string().optional(), deploymentId: z.string(), + buildMethod: appBuildMethodZodModel.optional(), }); export type DeploymentInfoModel = z.infer; export type DeploymentStatus = z.infer; - diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index 76ea0687..183e0e90 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -8,6 +8,7 @@ export const AppModel = z.object({ appType: z.string(), projectId: z.string(), sourceType: z.string(), + buildMethod: z.string(), containerImageSource: z.string().nullish(), containerRegistryUsername: z.string().nullish(), containerRegistryPassword: z.string().nullish(), diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index 918d0bc8..2f16133c 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -8,6 +8,7 @@ export class Constants { static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit'; static readonly QS_ANNOTATION_GIT_COMMIT_MESSAGE = 'qs-git-commit-message'; static readonly QS_ANNOTATION_BUILD_QUEUED_AT = 'qs-build-queued-at'; + static readonly QS_ANNOTATION_BUILD_METHOD = 'qs-build-method'; static readonly K3S_JOIN_TOKEN = 'k3sJoinToken'; static readonly QS_NAMESPACE = 'quickstack'; static readonly QS_APP_NAME = 'quickstack'; @@ -25,4 +26,5 @@ export class Constants { static readonly TOLERATION_FOR_EXECUTED_CRON_BACKUPS_MS = 60 * 60 * 1000; // 60 minutes; static readonly BUILD_NODE_K3S_NATIVE_VALUE = 'k3s-native'; static readonly BUILD_AUTO_NODE_VALUE = '__auto__'; -} \ No newline at end of file + static readonly QS_BUILD_INIT_CONTAINER_NAME = 'build-queue-init'; +} diff --git a/vitest.config.ts b/vitest.config.ts index c6ed17a0..9ad6618a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,15 @@ export default defineConfig({ name: 'jsdom', environment: 'jsdom', include: ['src/**/*.spec.ts', 'src/**/*.test.ts'], + exclude: ['src/__tests__/integration/**/*.spec.ts', 'src/__tests__/integration/**/*.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'node-integration', + environment: 'node', + include: ['src/__tests__/integration/**/*.spec.ts', 'src/__tests__/integration/**/*.test.ts'], }, }, ], @@ -26,4 +35,4 @@ export default defineConfig({ reportsDirectory: 'coverage', }, }, -}) \ No newline at end of file +})