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
4 changes: 2 additions & 2 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
run: yarn test

build-and-push-amd64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: test
if: ${{ needs.test.result == 'success' }}
steps:
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:
quickstack/quickstack:${{ github.ref_name }}-amd64

build-and-push-arm64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
needs: test
if: ${{ needs.test.result == 'success' }}
steps:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/canary-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
run: yarn test

build-and-push-amd64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: test
if: ${{ needs.test.result == 'success' }}
steps:
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:
quickstack/quickstack:canary-amd64

build-and-push-arm64:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
needs: test
if: ${{ needs.test.result == 'success' }}
steps:
Expand Down
246 changes: 246 additions & 0 deletions src/__tests__/server/build.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Mock heavy dependencies that cannot run in jsdom
jest.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} }));
jest.mock('@/server/adapter/db.client', () => ({ default: { client: {} } }));
jest.mock('@/server/services/namespace.service', () => ({ default: {} }));
jest.mock('@/server/services/registry.service', () => ({ default: {}, BUILD_NAMESPACE: 'qs-build' }));
jest.mock('@/server/services/param.service', () => ({ default: {}, ParamService: {} }));
jest.mock('@/server/services/cluster.service', () => ({ default: {} }));
jest.mock('@/server/services/build-init-container.service', () => ({ default: {} }));
jest.mock('@/server/services/git.service', () => ({ default: {} }));
jest.mock('@/server/services/pod.service', () => ({ default: {} }));
jest.mock('@/server/services/deployment-logs.service', () => ({ dlog: jest.fn() }));

import buildService from '@/server/services/build.service';
import { V1JobStatus } from '@kubernetes/client-node';

describe('BuildService.getJobStatusString', () => {

describe('undefined / empty status', () => {
it('returns UNKNOWN when status is undefined', () => {
expect(buildService.getJobStatusString(undefined)).toBe('UNKNOWN');
});

it('returns UNKNOWN when status is an empty object', () => {
expect(buildService.getJobStatusString({})).toBe('UNKNOWN');
});

it('returns UNKNOWN when all numeric fields are 0', () => {
const status: V1JobStatus = { ready: 0, succeeded: 0, failed: 0, terminating: 0, active: 0 };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});
});

describe('RUNNING — ready > 0', () => {
it('returns RUNNING when ready is 1', () => {
const status: V1JobStatus = { ready: 1 };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('returns RUNNING when ready is greater than 1', () => {
const status: V1JobStatus = { ready: 3 };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('does NOT return RUNNING when ready is 0', () => {
const status: V1JobStatus = { ready: 0 };
expect(buildService.getJobStatusString(status)).not.toBe('RUNNING');
});

it('does NOT return RUNNING when ready is undefined', () => {
const status: V1JobStatus = { ready: undefined };
expect(buildService.getJobStatusString(status)).not.toBe('RUNNING');
});
});

describe('SUCCEEDED — succeeded > 0', () => {
it('returns SUCCEEDED when succeeded is 1', () => {
const status: V1JobStatus = { succeeded: 1 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('returns SUCCEEDED when succeeded is greater than 1', () => {
const status: V1JobStatus = { succeeded: 5 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('does NOT return SUCCEEDED when succeeded is 0', () => {
const status: V1JobStatus = { succeeded: 0 };
expect(buildService.getJobStatusString(status)).not.toBe('SUCCEEDED');
});

it('does NOT return SUCCEEDED when succeeded is undefined', () => {
const status: V1JobStatus = {};
expect(buildService.getJobStatusString(status)).not.toBe('SUCCEEDED');
});
});

describe('SUCCEEDED — completionTime set', () => {
it('returns SUCCEEDED when completionTime is set and no other fields match', () => {
const status: V1JobStatus = { completionTime: new Date('2024-01-01T00:00:00Z') };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('returns SUCCEEDED via completionTime even when succeeded is 0', () => {
const status: V1JobStatus = { completionTime: new Date(), succeeded: 0, failed: 0, terminating: 0, active: 0, ready: 0 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});
});

describe('FAILED — failed > 0', () => {
it('returns FAILED when failed is 1', () => {
const status: V1JobStatus = { failed: 1 };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('returns FAILED when failed is greater than 1', () => {
const status: V1JobStatus = { failed: 4 };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('does NOT return FAILED when failed is 0', () => {
const status: V1JobStatus = { failed: 0 };
expect(buildService.getJobStatusString(status)).not.toBe('FAILED');
});

it('does NOT return FAILED when failed is undefined', () => {
const status: V1JobStatus = {};
expect(buildService.getJobStatusString(status)).not.toBe('FAILED');
});
});

describe('PENDING — active > 0', () => {
it('returns PENDING when active is 1 and no other indicator is set', () => {
const status: V1JobStatus = { active: 1 };
expect(buildService.getJobStatusString(status)).toBe('PENDING');
});

it('returns PENDING when active is greater than 1', () => {
const status: V1JobStatus = { active: 2 };
expect(buildService.getJobStatusString(status)).toBe('PENDING');
});

it('does NOT return PENDING when active is 0', () => {
const status: V1JobStatus = { active: 0 };
expect(buildService.getJobStatusString(status)).not.toBe('PENDING');
});

it('does NOT return PENDING when active is undefined', () => {
const status: V1JobStatus = {};
expect(buildService.getJobStatusString(status)).not.toBe('PENDING');
});
});

describe('UNKNOWN — terminating > 0', () => {
it('returns UNKNOWN when terminating is 1', () => {
const status: V1JobStatus = { terminating: 1 };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});

it('returns UNKNOWN when terminating is greater than 1', () => {
const status: V1JobStatus = { terminating: 3 };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});
});

describe('priority ordering', () => {
it('ready takes priority over succeeded', () => {
const status: V1JobStatus = { ready: 1, succeeded: 1 };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('ready takes priority over failed', () => {
const status: V1JobStatus = { ready: 1, failed: 1 };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('ready takes priority over active', () => {
const status: V1JobStatus = { ready: 1, active: 1 };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('ready takes priority over completionTime', () => {
const status: V1JobStatus = { ready: 1, completionTime: new Date() };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('failed takes priority over succeded', () => {
const status: V1JobStatus = { succeeded: 1, failed: 1 };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('succeeded takes priority over terminating', () => {
const status: V1JobStatus = { succeeded: 1, terminating: 1 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('succeeded takes priority over active', () => {
const status: V1JobStatus = { succeeded: 1, active: 1 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('failed takes priority over terminating', () => {
const status: V1JobStatus = { failed: 1, terminating: 0, active: 0 };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('failed takes priority over active', () => {
const status: V1JobStatus = { failed: 1, active: 1 };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('terminating takes priority over completionTime', () => {
const status: V1JobStatus = { terminating: 1, completionTime: new Date() };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});

it('terminating takes priority over active', () => {
const status: V1JobStatus = { terminating: 1, active: 1 };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});

it('completionTime takes priority over active', () => {
const status: V1JobStatus = { completionTime: new Date(), active: 1 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});
});

describe('realistic Kubernetes job lifecycle states', () => {
it('newly created job — active pod, not yet ready', () => {
const status: V1JobStatus = { active: 1, ready: 0, startTime: new Date() };
expect(buildService.getJobStatusString(status)).toBe('PENDING');
});

it('running job — pod active and ready', () => {
const status: V1JobStatus = { active: 1, ready: 1, startTime: new Date() };
expect(buildService.getJobStatusString(status)).toBe('RUNNING');
});

it('completed job — succeeded count set with completionTime', () => {
const startTime = new Date('2024-01-01T10:00:00Z');
const completionTime = new Date('2024-01-01T10:05:00Z');
const status: V1JobStatus = { succeeded: 1, active: 0, startTime, completionTime };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});

it('failed job — failed count set, backoffLimit reached', () => {
const status: V1JobStatus = { failed: 1, active: 0, startTime: new Date() };
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('job with multiple retry failures', () => {
const status: V1JobStatus = { failed: 3, active: 1 };
// failed takes priority over active per implementation order
expect(buildService.getJobStatusString(status)).toBe('FAILED');
});

it('terminating job — pod shutting down', () => {
const status: V1JobStatus = { terminating: 1, active: 0 };
expect(buildService.getJobStatusString(status)).toBe('UNKNOWN');
});

it('completed via completionTime only (succeeded field absent)', () => {
const status: V1JobStatus = { completionTime: new Date('2024-06-01T12:00:00Z'), active: 0 };
expect(buildService.getJobStatusString(status)).toBe('SUCCEEDED');
});
});
});
6 changes: 6 additions & 0 deletions src/app/api/deployment-status/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import k3s from "@/server/adapter/kubernetes-api.adapter";
import deploymentLiveStatusService from "@/server/services/deployment-live-status.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";
import { V1Deployment } from "@kubernetes/client-node";
import * as k8s from '@kubernetes/client-node';
Expand All @@ -12,6 +14,10 @@ export async function POST(request: Request) {

const session = await getAuthUserSession();

// starts the buildwatch service if not already running.
buildWatchService.startWatch();
deploymentEventWatchService.startWatch();

const encoder = new TextEncoder();
let shouldStopStreaming = false;
let watchRequest: { abort: () => void } | null = null;
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/init/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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";
import { NextResponse } from "next/server";

// Prevents this route's response from being cached
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
return simpleRoute(async () => {
const url = new URL(request.url);
const key = url.searchParams.get("key");

if (!globalThis.quickStackInitKey || key !== globalThis.quickStackInitKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await buildWatchService.startWatch();
await deploymentEventWatchService.startWatch();

console.log('Initialized services successfully via init route.');
return NextResponse.json({ status: "ok" });
});
}
7 changes: 7 additions & 0 deletions src/app/api/v1/webhook/deploy/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import k3s from "@/server/adapter/kubernetes-api.adapter";
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.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";
import { Informer, V1Pod } from "@kubernetes/client-node";
import { NextResponse } from "next/server";
Expand All @@ -20,6 +22,11 @@ const routeLogic = (request: Request) => simpleRoute(async () => {
});

const app = await appService.getByWebhookId(id);

// starts the buildwatch service if not already running.
buildWatchService.startWatch();
deploymentEventWatchService.startWatch();

await appService.buildAndDeploy(app.id, true);

return NextResponse.json({
Expand Down
20 changes: 20 additions & 0 deletions src/app/builds/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use server'

import buildService from "@/server/services/build.service";
import { getAuthUserSession, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils";
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { UserGroupUtils } from "@/shared/utils/role.utils";

export const getAllBuildsAction = async () =>
simpleAction(async () => {
const session = await getAuthUserSession();
const builds = await buildService.getAllBuilds();
return builds.filter((build) => UserGroupUtils.sessionHasReadAccessForApp(session, build.appId));
});

export const deleteBuildAction = async (buildName: string) =>
simpleAction(async () => {
await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName));
await buildService.deleteBuild(buildName);
return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.');
});
Loading
Loading