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
+})