first version of container build logs

This commit is contained in:
biersoeckli
2024-10-31 15:12:33 +00:00
parent d56ecffae6
commit 6df636ce21
7 changed files with 139 additions and 29 deletions

View File

@@ -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} />

View 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 >
</>;
}

View File

@@ -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
View 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>;

View File

@@ -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();

View File

@@ -15,4 +15,8 @@ export class Tags {
static app(appId: string) {
return `app-${appId}`;
}
static appBuilds(appId: string) {
return `app-build-${appId}`;
}
}

View File

@@ -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}`;
}
}