Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 21 additions & 1 deletion src/__tests__/k3s-test.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/deployment-status/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();

Expand Down Expand Up @@ -117,4 +118,4 @@ export async function POST(request: Request) {
},
});
});
}
}
2 changes: 2 additions & 0 deletions src/app/api/init/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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());

Expand Down
10 changes: 2 additions & 8 deletions src/app/api/pod-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand All @@ -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 });
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/app/builds/builds-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export default function BuildsTable({
</span>
)],
['name', 'Build Job', false],
['buildMethod', 'Build Method', true, (item) => (
<span className="text-muted-foreground text-sm">
{item.buildMethod === 'DOCKERFILE' ? 'Dockerfile' : 'Railpack'}
</span>
)],
['startTime', 'Started At', true, (item) => formatDateTime(item.startTime)],
['completionTime', 'Duration', true, (item) => {
const start = new Date(item.startTime).getTime();
Expand Down
Loading
Loading