mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-05-18 07:48:32 -05:00
411 lines
16 KiB
TypeScript
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;
|