Files
QuickStack/src/server/services/build.service.ts
T
2024-11-28 14:29:58 +00:00

411 lines
16 KiB
TypeScript

import { AppExtendedModel } from "@/shared/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Job, V1JobStatus } from "@kubernetes/client-node";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import { BuildJobModel } from "@/shared/model/build-job";
import { ServiceException } from "@/shared/model/service.exception.model";
import { PodsInfoModel } from "@/shared/model/pods-info.model";
import namespaceService from "./namespace.service";
import { Constants } from "../../shared/utils/constants";
import gitService from "./git.service";
import deploymentService from "./deployment.service";
import deploymentLogService, { dlog } from "./deployment-logs.service";
import podService from "./pod.service";
import stream from "stream";
const kanikoImage = "gcr.io/kaniko-project/executor:latest";
const REGISTRY_NODE_PORT = 30100;
const REGISTRY_CONTAINER_PORT = 5000;
const REGISTRY_SVC_NAME = 'registry-svc';
export const BUILD_NAMESPACE = "registry-and-build";
export const REGISTRY_URL_EXTERNAL = `localhost:${REGISTRY_NODE_PORT}`;
export const REGISTRY_URL_INTERNAL = `${REGISTRY_SVC_NAME}.${BUILD_NAMESPACE}.svc.cluster.local:${REGISTRY_CONTAINER_PORT}`
class BuildService {
async buildApp(deploymentId: string, app: AppExtendedModel, forceBuild: boolean = false): Promise<[string, string, Promise<void>]> {
await namespaceService.createNamespaceIfNotExists(BUILD_NAMESPACE);
await this.deployRegistryIfNotExists();
const buildsForApp = await this.getBuildsForApp(app.id);
if (buildsForApp.some((job) => job.status === 'RUNNING')) {
throw new ServiceException("A build job is already running for this app.");
}
dlog(deploymentId, `Initialized app build...`);
dlog(deploymentId, `Trying to clone repository...`);
// Check if last build is already up to date with data in git repo
const latestSuccessfulBuld = buildsForApp.find(x => x.status === 'SUCCEEDED');
const latestRemoteGitHash = await gitService.getLatestRemoteCommitHash(app);
dlog(deploymentId, `Cloned repository successfully`);
dlog(deploymentId, `Latest remote git hash: ${latestRemoteGitHash}`);
if (!forceBuild && latestSuccessfulBuld?.gitCommit && latestRemoteGitHash &&
latestSuccessfulBuld?.gitCommit === latestRemoteGitHash) {
await dlog(deploymentId, `Latest build is already up to date with git repository, using container from last build.`);
console.log(`Last build is already up to date with data in git repo for app ${app.id}`);
// todo check if the container is still in registry
return [latestSuccessfulBuld.name, latestRemoteGitHash, Promise.resolve()];
}
return await this.createAndStartBuildJob(deploymentId, app, latestRemoteGitHash);
}
private async createAndStartBuildJob(deploymentId: string, app: AppExtendedModel, latestRemoteGitHash: string): Promise<[string, string, Promise<void>]> {
const buildName = KubeObjectNameUtils.addRandomSuffix(KubeObjectNameUtils.toJobName(app.id));
dlog(deploymentId, `Creating build job with name: ${buildName}`);
const jobDefinition: V1Job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: buildName,
namespace: BUILD_NAMESPACE,
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
[Constants.QS_ANNOTATION_GIT_COMMIT]: latestRemoteGitHash,
[Constants.QS_ANNOTATION_DEPLOYMENT_ID]: deploymentId,
}
},
spec: {
ttlSecondsAfterFinished: 2592000, // 30 days
template: {
spec: {
containers: [
{
name: buildName,
image: kanikoImage,
args: [
`--dockerfile=${app.dockerfilePath}`,
`--insecure`,
`--log-format=text`,
`--context=${app.gitUrl!.replace("https://", "git://")}#refs/heads/${app.gitBranch}`, // todo change to shared folder
`--destination=${this.createInternalContainerRegistryUrlForAppId(app.id)}`
]
},
],
restartPolicy: "Never",
},
},
backoffLimit: 0,
},
};
if (app.gitUsername && app.gitToken) {
jobDefinition.spec!.template.spec!.containers[0].env = [
{
name: "GIT_USERNAME",
value: app.gitUsername
},
{
name: "GIT_PASSWORD",
value: app.gitToken
}
];
}
await k3s.batch.createNamespacedJob(BUILD_NAMESPACE, jobDefinition);
await dlog(deploymentId, `Build job ${buildName} started successfully`);
await this.logBuildOutput(deploymentId, buildName);
const buildJobPromise = this.waitForJobCompletion(jobDefinition.metadata!.name!)
return [buildName, latestRemoteGitHash, buildJobPromise];
}
async logBuildOutput(deploymentId: string, buildName: string) {
const pod = await this.getPodForJob(buildName);
await podService.waitUntilPodIsRunningFailedOrSucceded(BUILD_NAMESPACE, pod.podName);
const logStream = new stream.PassThrough();
const k3sStreamRequest = await k3s.log.log(BUILD_NAMESPACE, pod.podName, pod.containerName, logStream, {
follow: true,
tailLines: undefined,
timestamps: true,
pretty: false,
previous: false
});
logStream.on('data', async (chunk) => {
await dlog(deploymentId, chunk.toString(), false, false);
});
logStream.on('error', async (error) => {
console.error("Error in build log stream for deployment " + deploymentId, error);
await dlog(deploymentId, '[ERROR] An unexpected error occurred while streaming logs.');
});
logStream.on('end', async () => {
console.log(`[END] Log stream ended for build process: ${buildName}`);
await dlog(deploymentId, `[END] Log stream ended for build process: ${buildName}`);
});
}
createInternalContainerRegistryUrlForAppId(appId?: string) {
if (!appId) {
return undefined;
}
return `${REGISTRY_URL_INTERNAL}/${appId}:latest`;
}
createContainerRegistryUrlForAppId(appId?: string) {
if (!appId) {
return undefined;
}
return `${REGISTRY_URL_EXTERNAL}/${appId}:latest`;
}
async deleteAllBuildsOfApp(appId: string) {
const jobNamePrefix = KubeObjectNameUtils.toJobName(appId);
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
const jobsOfBuild = jobs.body.items.filter((job) => job.metadata?.name?.startsWith(jobNamePrefix));
for (const job of jobsOfBuild) {
await this.deleteBuild(job.metadata?.name!);
}
}
async deleteAllBuildsOfProject(projectId: string) {
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
const jobsOfProject = jobs.body.items.filter((job) => job.metadata?.annotations?.[Constants.QS_ANNOTATION_PROJECT_ID] === projectId);
for (const job of jobsOfProject) {
await this.deleteBuild(job.metadata?.name!);
}
}
async deleteBuild(buildName: string) {
await k3s.batch.deleteNamespacedJob(buildName, BUILD_NAMESPACE);
console.log(`Deleted build job ${buildName}`);
}
async getBuildsForApp(appId: string) {
const jobNamePrefix = KubeObjectNameUtils.toJobName(appId);
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
const jobsOfBuild = jobs.body.items.filter((job) => job.metadata?.name?.startsWith(jobNamePrefix));
const builds = jobsOfBuild.map((job) => {
return {
name: job.metadata?.name,
startTime: job.status?.startTime,
status: this.getJobStatusString(job.status),
gitCommit: job.metadata?.annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT],
deploymentId: job.metadata?.annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID],
} as BuildJobModel;
});
builds.sort((a, b) => {
if (a.startTime && b.startTime) {
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
}
return 0;
});
return builds;
}
async getPodForJob(jobName: string) {
const res = await k3s.core.listNamespacedPod(BUILD_NAMESPACE, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
const jobs = res.body.items;
if (jobs.length === 0) {
throw new ServiceException(`No pod found for job ${jobName}`);
}
const pod = jobs[0];
return {
podName: pod.metadata?.name!,
containerName: pod.spec?.containers?.[0].name!
} as PodsInfoModel;
}
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(jobName);
if (jobStatus === 'UNKNOWN') {
console.log(`Job ${jobName} not found.`);
clearInterval(intervalId);
reject(new Error(`Job ${jobName} not found.`));
return;
}
if (jobStatus === 'SUCCEEDED') {
clearInterval(intervalId);
console.log(`Job ${jobName} completed successfully.`);
resolve();
} else if (jobStatus === 'FAILED') {
clearInterval(intervalId);
console.log(`Job ${jobName} failed.`);
reject(new Error(`Job ${jobName} failed.`));
} else {
console.log(`Job ${jobName} is still running...`);
}
} catch (err) {
clearInterval(intervalId);
reject(err);
}
}, POLL_INTERVAL);
});
}
async getJobStatus(buildName: string): Promise<'UNKNOWN' | 'RUNNING' | 'FAILED' | 'SUCCEEDED'> {
try {
const response = await k3s.batch.readNamespacedJobStatus(buildName, BUILD_NAMESPACE);
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';
}
async deployRegistryIfNotExists() {
const deployments = await k3s.apps.listNamespacedDeployment(BUILD_NAMESPACE);
if (deployments.body.items.length > 0) {
return;
}
console.log("Deploying registry because it is not deployed...");
// Create Namespace
console.log("Creating namespace...");
await namespaceService.createNamespaceIfNotExists(BUILD_NAMESPACE);
// Create PersistentVolumeClaim
console.log("Creating Registry PVC...");
const pvcManifest = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: 'registry-data-pvc',
namespace: BUILD_NAMESPACE,
},
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: 'longhorn',
resources: {
requests: {
storage: '5Gi',
},
},
},
};
await k3s.core.createNamespacedPersistentVolumeClaim(BUILD_NAMESPACE, pvcManifest)
// Create Deployment
console.log("Creating Registry Deployment...");
const deploymentManifest = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'registry',
namespace: BUILD_NAMESPACE,
},
spec: {
replicas: 1,
strategy: {
type: 'Recreate',
},
selector: {
matchLabels: {
app: 'registry',
},
},
template: {
metadata: {
labels: {
app: 'registry',
},
},
spec: {
containers: [
{
name: 'registry',
image: 'registry:latest',
volumeMounts: [
{
name: 'registry-data-pv',
mountPath: '/var/lib/registry',
},
],
},
],
volumes: [
{
name: 'registry-data-pv',
persistentVolumeClaim: {
claimName: 'registry-data-pvc',
},
},
],
},
},
},
};
await k3s.apps.createNamespacedDeployment(BUILD_NAMESPACE, deploymentManifest);
// Create Service
console.log("Creating Registry Service...");
const serviceManifest = {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: REGISTRY_SVC_NAME,
namespace: BUILD_NAMESPACE,
},
spec: {
selector: {
app: 'registry',
},
ports: [
{
nodePort: REGISTRY_NODE_PORT,
protocol: 'TCP',
port: REGISTRY_CONTAINER_PORT,
targetPort: REGISTRY_CONTAINER_PORT,
},
],
type: 'NodePort',
},
};
await k3s.core.createNamespacedService(BUILD_NAMESPACE, serviceManifest);
console.log("Waiting for registry to be deployed...");
const pods = await podService.getPodsForApp(BUILD_NAMESPACE, 'registry');
if (pods.length === 1) {
await podService.waitUntilPodIsRunningFailedOrSucceded(BUILD_NAMESPACE, pods[0].podName)
}
console.log("Registry deployed successfully.");
}
}
const buildService = new BuildService();
export default buildService;