updated build overview to deployment overview

This commit is contained in:
biersoeckli
2024-11-01 14:19:36 +00:00
parent 45aaecc534
commit 169b5cdc9c
11 changed files with 240 additions and 34 deletions
+8
View File
@@ -1,6 +1,7 @@
'use server'
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
@@ -9,3 +10,10 @@ export const deploy = async (appId: string) =>
await getAuthUserSession();
await appService.buildAndDeploy(appId);
});
export const test = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
const app = await appService.getExtendedById(appId);
await deploymentService.getDeploymentHistory(app.projectId, app.id);
});
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { deploy } from "./action";
import { deploy, test } from "./action";
import { AppExtendedModel } from "@/model/app-extended.model";
export default function AppActionButtons({
@@ -14,7 +14,7 @@ export default function AppActionButtons({
return <Card>
<CardContent className="p-4 flex gap-4">
<Button onClick={() => deploy(app.id)}>Deploy</Button>
<Button variant="secondary">Start</Button>
<Button onClick={() => test(app.id)} variant="secondary">Start</Button>
<Button variant="secondary">Rebuild</Button>
</CardContent>
</Card >;
@@ -1,22 +1,20 @@
'use server'
import { AppRateLimitsModel, appRateLimitsZodModel } from "@/model/app-rate-limits.model";
import { appSourceInfoContainerZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/model/app-source-info.model";
import { AuthFormInputSchema, authFormInputSchemaZod } from "@/model/auth-form";
import { BuildJobModel } from "@/model/build-job";
import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/model/server-action-error-return.model";
import { ServiceException } from "@/model/service.exception.model";
import { DeploymentInfoModel } from "@/model/deployment";
import { ServerActionResult, SuccessActionResult } from "@/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import buildService from "@/server/services/build.service";
import userService from "@/server/services/user.service";
import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
export const getBuildsForApp = async (appId: string) =>
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
return await buildService.getBuildsForApp(appId);
}) as Promise<ServerActionResult<unknown, BuildJobModel[]>>;
const app = await appService.getExtendedById(appId);
return await deploymentService.getDeploymentHistory(app.projectId, appId);
}) as Promise<ServerActionResult<unknown, DeploymentInfoModel[]>>;
export const deleteBuild = async (buildName: string) =>
simpleAction(async () => {
@@ -5,7 +5,7 @@ import { formatDateTime } from "@/lib/format.utils";
import { AppExtendedModel } from "@/model/app-extended.model";
import { BuildJobModel } from "@/model/build-job";
import { useEffect, useState } from "react";
import { deleteBuild, getBuildsForApp } from "./actions";
import { deleteBuild, getDeploymentsAndBuildsForApp } from "./actions";
import { set } from "date-fns";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Item } from "@radix-ui/react-dropdown-menu";
@@ -13,6 +13,8 @@ import BuildStatusBadge from "./build-status-badge";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/lib/zustand.states";
import { Toast } from "@/lib/toast.utils";
import { DeploymentInfoModel } from "@/model/deployment";
import DeploymentStatusBadge from "./deployment-status-badge";
export default function BuildsTab({
app
@@ -21,13 +23,13 @@ export default function BuildsTab({
}) {
const { openDialog } = useConfirmDialog();
const [appBuilds, setAppBuilds] = useState<BuildJobModel[] | undefined>(undefined);
const [appBuilds, setAppBuilds] = useState<DeploymentInfoModel[] | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const updateBuilds = async () => {
setError(undefined);
try {
const response = await getBuildsForApp(app.id);
const response = await getDeploymentsAndBuildsForApp(app.id);
if (response.status === 'success' && response.data) {
setAppBuilds(response.data);
} else {
@@ -70,25 +72,25 @@ export default function BuildsTab({
return <>
<Card>
<CardHeader>
<CardTitle>Container Builds</CardTitle>
<CardDescription>This is an overview of the last container builds for this App.</CardDescription>
<CardTitle>Deployments</CardTitle>
<CardDescription>This is an overview of the last deplyoments for this App.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!appBuilds ? <FullLoadingSpinner /> :
<SimpleDataTable columns={[
['name', 'Name', false],
['status', 'Status', true, (item) => <BuildStatusBadge>{item.status}</BuildStatusBadge>],
["startTime", "Started At", true, (item) => formatDateTime(item.startTime)],
['replicasetName', 'Deployment Name', false],
['buildJobName', 'Build Job Name', false],
['status', 'Status', true, (item) => <DeploymentStatusBadge>{item.status}</DeploymentStatusBadge>],
["startTime", "Started At", true, (item) => formatDateTime(item.createdAt)],
]}
data={appBuilds}
hideSearchBar={true}
actionCol={(item) => {
// todo add: <>{ ?}</>
return <>
<div className="flex gap-4">
<div className="flex-1"></div>
<Button variant="secondary">Show Logs</Button>
{item.status === 'RUNNING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.name)}>Stop Build</Button>}
{item.buildJobName && <Button variant="secondary">Show Logs</Button>}
{item.buildJobName && item.status === 'BUILDING' && <Button variant="destructive" onClick={() => deleteBuildClick(item.buildJobName!)}>Stop Build</Button>}
</div>
</>
}}
@@ -0,0 +1,70 @@
'use client'
import { DeplyomentStatus } from "@/model/deployment";
export default function DeploymentStatusBadge(
{
children
}: {
children: DeplyomentStatus
}
) {
return (<>
<span className={'px-2 py-1 rounded-lg text-sm font-semibold ' + getBackgroundColorForStatus(children) + ' ' + getTextColorForStatus(children)}>{getTextForStatus(children)}</span>
</>)
}
function getTextForStatus(status: DeplyomentStatus) {
switch (status) {
case 'SHUTDOWN':
return 'Shutdown';
case 'BUILDING':
return 'Building';
case 'ERROR':
return 'Error';
case 'DEPLOYING':
return 'Deploying';
case 'DEPLOYED':
return 'Deployed';
default:
return 'Unknown';
}
}
function getBackgroundColorForStatus(status: DeplyomentStatus) {
switch (status) {
case 'SHUTDOWN':
return 'bg-slate-100';
case 'ERROR':
return 'bg-red-100';
case 'BUILDING':
return 'bg-blue-100';
case 'DEPLOYING':
return 'bg-blue-100';
case 'DEPLOYED':
return 'bg-green-100';
default:
return 'bg-slate-100';
}
}
function getTextColorForStatus(status: DeplyomentStatus) {
switch (status) {
case 'SHUTDOWN':
return 'text-slate-800';
case 'ERROR':
return 'text-red-800';
case 'BUILDING':
return 'text-blue-800';
case 'DEPLOYING':
return 'text-blue-800';
case 'DEPLOYED':
return 'text-green-800';
default:
return 'text-slate-800';
}
}
+1 -1
View File
@@ -3,5 +3,5 @@ import { redirect } from "next/navigation";
// redirects to default route "general" for the app
export async function GET(request: Request) {
const url = new URL(request.url);
redirect(`/project/app/general?appId=${url.searchParams.get("appId")}`);
redirect(`/project/app/overview?appId=${url.searchParams.get("appId")}`);
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
export const deploymentStatusEnumZod = z.union([
z.literal('UNKNOWN'),
z.literal('BUILDING'),
z.literal('ERROR'),
z.literal('DEPLOYED'),
z.literal('DEPLOYING'),
z.literal('SHUTDOWN'),
]);
export const deploymentInfoZodModel = z.object({
replicasetName: z.string().optional(),
buildJobName: z.string().optional(),
createdAt: z.date(),
status: deploymentStatusEnumZod
});
export type DeploymentInfoModel = z.infer<typeof deploymentInfoZodModel>;
export type DeplyomentStatus = z.infer<typeof deploymentStatusEnumZod>;
+6 -2
View File
@@ -15,8 +15,12 @@ class AppService {
const app = await this.getExtendedById(appId);
if (app.sourceType === 'GIT') {
// first make build
await deploymentService.createNamespaceIfNotExists(buildNamespace)
await buildService.buildApp(app);
await deploymentService.createNamespaceIfNotExists(buildNamespace);
const [buildJobName, buildPromise] = await buildService.buildApp(app);
buildPromise.then(async () => {
console.warn('Build job finished, deploying...');
await deploymentService.createDeployment(app, buildJobName);
});
} else {
// only deploy
await deploymentService.createDeployment(app);
+12 -6
View File
@@ -1,6 +1,6 @@
import { AppExtendedModel } from "@/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Deployment, V1Job, V1JobStatus } from "@kubernetes/client-node";
import { 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";
@@ -13,7 +13,7 @@ export const buildNamespace = "registry-and-build";
class BuildService {
async buildApp(app: AppExtendedModel) {
async buildApp(app: AppExtendedModel): Promise<[string, Promise<void>]> {
const runningJobsForApp = await this.getBuildsForApp(app.id);
if (runningJobsForApp.some((job) => job.status === 'RUNNING')) {
@@ -38,7 +38,7 @@ class BuildService {
image: kanikoImage,
args: [`--dockerfile=${app.dockerfilePath}`,
`--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`,
`--destination=${registryURL}/${app.id}`]
`--destination=${this.createContainerRegistryUrlForAppId(app.id)}`]
},
],
restartPolicy: "Never",
@@ -62,11 +62,17 @@ class BuildService {
}
await k3s.batch.createNamespacedJob(buildNamespace, jobDefinition);
revalidateTag(Tags.appBuilds(app.id));
this.waitForJobCompletion(jobDefinition.metadata!.name!)
.then(() => revalidateTag(Tags.appBuilds(app.id)))
.catch((err) => revalidateTag(Tags.appBuilds(app.id)));
const buildJobPromise = this.waitForJobCompletion(jobDefinition.metadata!.name!)
return [buildName, buildJobPromise];
}
createContainerRegistryUrlForAppId(appId?: string) {
if (!appId) {
return undefined;
}
return `${registryURL}/${appId}:latest`;
}
async deleteBuild(buildName: string) {
+91 -3
View File
@@ -1,6 +1,10 @@
import { AppExtendedModel } from "@/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Deployment } from "@kubernetes/client-node";
import buildService from "./build.service";
import { ListUtils } from "../utils/list.utils";
import { DeploymentInfoModel, DeplyomentStatus } from "@/model/deployment";
import { BuildJobStatus } from "@/model/build-job";
class DeploymentService {
@@ -81,15 +85,17 @@ class DeploymentService {
}
}
async createDeployment(app: AppExtendedModel) {
async createDeployment(app: AppExtendedModel, buildJobName?: string) {
await this.createNamespaceIfNotExists(app.projectId);
const existingDeployment = await this.getDeployment(app.projectId, app.id);
const body: V1Deployment = {
metadata: {
name: app.id
name: app.id,
},
spec: {
// strategy: 'rollingUpdate',
replicas: app.replicas,
selector: {
matchLabels: {
@@ -100,13 +106,18 @@ class DeploymentService {
metadata: {
labels: {
app: app.id
},
annotations: {
deploymentTimestamp: new Date().getTime() + "",
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
}
},
spec: {
containers: [
{
name: app.id,
image: app.containerImageSource as string,
image: !!buildJobName ? buildService.createContainerRegistryUrlForAppId(app.id) : app.containerImageSource as string,
imagePullPolicy: 'Always',
/*ports: [
{
containerPort: app.port
@@ -118,6 +129,9 @@ class DeploymentService {
}
}
};
if (buildJobName) {
body.spec!.template!.metadata!.annotations!.buildJobName = buildJobName; // add buildJobName to deployment
}
if (existingDeployment) {
const res = await k3s.apps.replaceNamespacedDeployment(app.id, app.projectId, body);
} else {
@@ -144,6 +158,80 @@ class DeploymentService {
await k3s.core.deleteNamespace(namespace);
}
}
/**
* Searches for Build Jobs (only for Git Projects) and ReplicaSets (for all projects) and returns a list of DeploymentModel
* Build are only included if they are in status RUNNING, FAILED or UNKNOWN. SUCCESSFUL builds are not included because they are already part of the ReplicaSet history.
* @param projectId
* @param appId
* @returns
*/
async getDeploymentHistory(projectId: string, appId: string): Promise<DeploymentInfoModel[]> {
const replicasetRevisions = await this.getReplicasetRevisionHistory(projectId, appId);
const builds = await buildService.getBuildsForApp(appId);
const runningOrFailedBuilds = builds
.filter((build) => ['RUNNING', 'FAILED', 'UNKNOWN'].includes(build.status))
.map((build) => {
return {
replicasetName: undefined,
createdAt: build.startTime!,
buildJobName: build.name!,
status: this.mapBuildStatusToDeploymentStatus(build.status)
}
});
replicasetRevisions.push(...runningOrFailedBuilds);
return ListUtils.sortByDate(replicasetRevisions, (i) => i.createdAt!, true);
}
mapBuildStatusToDeploymentStatus(buildJobStatus?: BuildJobStatus) {
const map = new Map<BuildJobStatus, DeplyomentStatus>([
['UNKNOWN', 'UNKNOWN'],
['RUNNING', 'BUILDING'],
['FAILED', 'ERROR']
]);
return map.get(buildJobStatus ?? 'UNKNOWN') ?? 'UNKNOWN';
}
async getReplicasetRevisionHistory(projectId: string, appId: string): Promise<DeploymentInfoModel[]> {
const deployment = await this.getDeployment(projectId, appId);
if (!deployment) {
return [];
}
// List ReplicaSets in the namespace to find those associated with the deployment
const replicaSetsForDeployment = await k3s.apps.listNamespacedReplicaSet(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
const revisions = replicaSetsForDeployment.body.items.map((rs, index) => {
let status = 'UNKNOWN' as DeplyomentStatus;
if (rs.status?.replicas === 0) {
status = 'SHUTDOWN';
} else if (rs.status?.replicas === rs.status?.readyReplicas) {
status = 'DEPLOYED';
} else if (rs.status?.replicas !== rs.status?.readyReplicas) {
status = 'DEPLOYING';
}
/*
Fields for Status:
availableReplicas: 1,
conditions: undefined,
fullyLabeledReplicas: 1,
observedGeneration: 3,
readyReplicas: 1,
replicas: 1
*/
return {
replicasetName: rs.metadata?.name!,
createdAt: rs.metadata?.creationTimestamp!,
buildJobName: rs.metadata?.annotations?.buildJobName!,
status: status
}
});
return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true);
}
}
const deploymentService = new DeploymentService();
+8
View File
@@ -1,5 +1,13 @@
export class ListUtils {
static sortByDate<T>(array: T[], dateSelector: (item: T) => Date, descending = false): T[] {
return array.toSorted((a, b) => {
const dateA = dateSelector(a);
const dateB = dateSelector(b);
return descending ? dateB.getTime() - dateA.getTime() : dateA.getTime() - dateB.getTime();
});
}
static distinctBy<T, TKey>(array: T[], keySelector: (item: T) => TKey): T[] {
const keys = new Set<TKey>();
const result = new Array<T>();