mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-07 12:10:19 -06:00
first version of container build logs
This commit is contained in:
@@ -9,13 +9,17 @@ import { App } from "@prisma/client";
|
||||
import DomainsList from "./domains/domains";
|
||||
import StorageList from "./storage/storages";
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
import { BuildJobModel } from "@/model/build-job";
|
||||
import BuildsTab from "./overview/builds-tab";
|
||||
|
||||
export default function AppTabs({
|
||||
app,
|
||||
tabName
|
||||
tabName,
|
||||
appBuilds
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
tabName: string;
|
||||
appBuilds: BuildJobModel[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -32,7 +36,9 @@ export default function AppTabs({
|
||||
<TabsTrigger value="domains">Domains</TabsTrigger>
|
||||
<TabsTrigger value="storage">Storage</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">Domains, Logs, etc.</TabsContent>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<BuildsTab app={app} appBuilds={appBuilds} />
|
||||
</TabsContent>
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<GeneralAppSource app={app} />
|
||||
<GeneralAppRateLimits app={app} />
|
||||
|
||||
45
src/app/project/app/[tabName]/overview/builds-tab.tsx
Normal file
45
src/app/project/app/[tabName]/overview/builds-tab.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDateTime } from "@/lib/format.utils";
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
import { BuildJobModel } from "@/model/build-job";
|
||||
|
||||
export default function BuildsTab({
|
||||
app,
|
||||
appBuilds
|
||||
}: {
|
||||
app: AppExtendedModel;
|
||||
appBuilds: BuildJobModel[];
|
||||
}) {
|
||||
|
||||
if (app.sourceType === 'container') {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Container Builds</CardTitle>
|
||||
<CardDescription>This is an overview of the last container builds for this App.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<SimpleDataTable columns={[
|
||||
['name', 'Name', false],
|
||||
['status', 'Status', true],
|
||||
["startTime", "Started At", true, (item) => formatDateTime(item.startTime)],
|
||||
]}
|
||||
data={appBuilds}
|
||||
hideSearchBar={true}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex-1"></div>
|
||||
<div>TODO BUTTON</div>
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
|
||||
</CardContent>
|
||||
</Card >
|
||||
</>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import AppTabs from "./app-tabs";
|
||||
import AppActionButtons from "./app-action-buttons";
|
||||
import buildService from "@/server/services/build.service";
|
||||
|
||||
export default async function AppPage({
|
||||
searchParams,
|
||||
@@ -24,6 +25,7 @@ export default async function AppPage({
|
||||
return <p>Could not find app with id {appId}</p>
|
||||
}
|
||||
const app = await appService.getExtendedById(appId);
|
||||
const builds = await buildService.getBuildsForApp(appId);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 p-8 pt-6">
|
||||
@@ -47,7 +49,7 @@ export default async function AppPage({
|
||||
subtitle={`App ID: ${app.id}`}>
|
||||
</PageTitle>
|
||||
<AppActionButtons app={app} />
|
||||
<AppTabs app={app} tabName={params.tabName} />
|
||||
<AppTabs app={app} appBuilds={builds} tabName={params.tabName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
11
src/model/build-job.ts
Normal file
11
src/model/build-job.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const buildJobSchemaZod = z.object({
|
||||
name: z.string(),
|
||||
startTime: z.date(),
|
||||
status: z.union([z.literal('UNKNOWN'), z.literal('RUNNING'), z.literal('FAILED'), z.literal('SUCCEEDED')]),
|
||||
});
|
||||
|
||||
export type BuildJobModel = z.infer<typeof buildJobSchemaZod>;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AppExtendedModel } from "@/model/app-extended.model";
|
||||
import k3s from "../adapter/kubernetes-api.adapter";
|
||||
import { V1Deployment, V1Job } from "@kubernetes/client-node";
|
||||
import { V1Deployment, V1Job, V1JobStatus } from "@kubernetes/client-node";
|
||||
import { StringUtils } from "../utils/string.utils";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
import { BuildJobModel } from "@/model/build-job";
|
||||
|
||||
const kanikoImage = "gcr.io/kaniko-project/executor:latest";
|
||||
export const registryURL = "registry-svc.registry-and-build.svc.cluster.local"
|
||||
@@ -9,24 +13,25 @@ export const buildNamespace = "registry-and-build";
|
||||
class BuildService {
|
||||
|
||||
async buildApp(app: AppExtendedModel) {
|
||||
const buildName = StringUtils.addRandomSuffix(StringUtils.toJobName(app.id));
|
||||
const jobDefinition: V1Job = {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: {
|
||||
name: `build-${app.id}`,
|
||||
name: buildName,
|
||||
namespace: buildNamespace,
|
||||
},
|
||||
spec: {
|
||||
ttlSecondsAfterFinished: 100,
|
||||
ttlSecondsAfterFinished: 2592000, // 30 days
|
||||
template: {
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: `build-${app.id}`,
|
||||
name: buildName,
|
||||
image: kanikoImage,
|
||||
args: [`--dockerfile=${app.dockerfilePath}`,
|
||||
`--context=${app.gitUrl!.replace("https://","git://")}#refs/heads/${app.gitBranch}`,
|
||||
`--destination=${registryURL}/${app.id}`]
|
||||
`--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`,
|
||||
`--destination=${registryURL}/${app.id}`]
|
||||
},
|
||||
],
|
||||
restartPolicy: "Never",
|
||||
@@ -49,16 +54,39 @@ class BuildService {
|
||||
];
|
||||
}
|
||||
await k3s.batch.createNamespacedJob(buildNamespace, jobDefinition);
|
||||
await this.waitForJobCompletion(buildNamespace, jobDefinition.metadata!.name!);
|
||||
revalidateTag(Tags.appBuilds(app.id));
|
||||
this.waitForJobCompletion(jobDefinition.metadata!.name!)
|
||||
.then(() => revalidateTag(Tags.appBuilds(app.id)))
|
||||
.catch((err) => revalidateTag(Tags.appBuilds(app.id)));
|
||||
|
||||
|
||||
}
|
||||
|
||||
async waitForJobCompletion(namespace:string, jobName:string) {
|
||||
const POLL_INTERVAL = 10000; // 10 seconds
|
||||
async getBuildsForApp(appId: string) {
|
||||
return await unstable_cache(async (appId: string) => {
|
||||
const jobNamePrefix = StringUtils.toJobName(appId);
|
||||
const jobs = await k3s.batch.listNamespacedJob(buildNamespace);
|
||||
const jobsOfBuild = jobs.body.items.filter((job) => job.metadata?.name?.startsWith(jobNamePrefix));
|
||||
return jobsOfBuild.map((job) => {
|
||||
return {
|
||||
name: job.metadata?.name,
|
||||
startTime: job.status?.startTime,
|
||||
status: this.getJobStatusString(job.status),
|
||||
} as BuildJobModel;
|
||||
});
|
||||
},
|
||||
[Tags.appBuilds(appId)], {
|
||||
tags: [Tags.appBuilds(appId)],
|
||||
revalidate: 10,
|
||||
})(appId);
|
||||
}
|
||||
|
||||
async waitForJobCompletion(jobName: string) {
|
||||
const POLL_INTERVAL = 10000; // 10 seconds
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const jobStatus = await this.getJobStatus(namespace, jobName);
|
||||
const jobStatus = await this.getJobStatus(jobName);
|
||||
if (jobStatus === 'UNKNOWN') {
|
||||
console.log(`Job ${jobName} not found.`);
|
||||
clearInterval(intervalId);
|
||||
@@ -84,29 +112,34 @@ class BuildService {
|
||||
});
|
||||
}
|
||||
|
||||
async getJobStatus(namespace:string, jobName:string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED'> {
|
||||
async getJobStatus(jobName: string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED'> {
|
||||
try {
|
||||
const response = await k3s.batch.readNamespacedJobStatus(jobName, namespace);
|
||||
const job = response.body;
|
||||
if (!job.status) {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
if ((job.status.active ?? 0) > 0) {
|
||||
return 'RUNNING';
|
||||
}
|
||||
if ((job.status.succeeded?? 0) > 0) {
|
||||
return 'SUCCEEDED';
|
||||
}
|
||||
|
||||
if ((job.status.failed ?? 0) > 0) {
|
||||
return 'FAILED';
|
||||
}
|
||||
const response = await k3s.batch.readNamespacedJobStatus(jobName, buildNamespace);
|
||||
const status = response.body.status;
|
||||
return this.getJobStatusString(status);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
getJobStatusString(status?: V1JobStatus) {
|
||||
if (!status) {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
if ((status.active ?? 0) > 0) {
|
||||
return 'RUNNING';
|
||||
}
|
||||
if ((status.succeeded ?? 0) > 0) {
|
||||
return 'SUCCEEDED';
|
||||
}
|
||||
|
||||
if ((status.failed ?? 0) > 0) {
|
||||
return 'FAILED';
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const buildService = new BuildService();
|
||||
|
||||
@@ -15,4 +15,8 @@ export class Tags {
|
||||
static app(appId: string) {
|
||||
return `app-${appId}`;
|
||||
}
|
||||
|
||||
static appBuilds(appId: string) {
|
||||
return `app-build-${appId}`;
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,13 @@ export class StringUtils {
|
||||
static toAppId(str: string): string {
|
||||
return `app-${StringUtils.toObjectId(str)}`;
|
||||
}
|
||||
|
||||
static toJobName(appId: string) {
|
||||
return `build-${appId}`;
|
||||
}
|
||||
|
||||
static addRandomSuffix(str: string): string {
|
||||
const randomString = crypto.randomBytes(4).toString('hex');
|
||||
return `${str}-${randomString}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user