mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-16 08:49:33 -06:00
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { AppExtendedModel } from "@/model/app-extended.model";
|
|
import k3s from "../adapter/kubernetes-api.adapter";
|
|
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
|
|
import buildService from "./build.service";
|
|
import { ListUtils } from "../utils/list.utils";
|
|
import { DeploymentInfoModel, DeplyomentStatus } from "@/model/deployment-info.model";
|
|
import { BuildJobStatus } from "@/model/build-job";
|
|
import { ServiceException } from "@/model/service.exception.model";
|
|
import { PodsInfoModel } from "@/model/pods-info.model";
|
|
import { StringUtils } from "../utils/string.utils";
|
|
|
|
class DeploymentService {
|
|
|
|
async getNamespaces() {
|
|
const k3sResponse = await k3s.core.listNamespace();
|
|
return k3sResponse.body.items.map((item) => item.metadata?.name).filter((name) => !!name);
|
|
}
|
|
|
|
async getDeployment(projectId: string, appId: string) {
|
|
const allDeployments = await k3s.apps.listNamespacedDeployment(projectId);
|
|
if (allDeployments.body.items.some((item) => item.metadata?.name === appId)) {
|
|
const res = await k3s.apps.readNamespacedDeployment(appId, projectId);
|
|
return res.body;
|
|
}
|
|
}
|
|
|
|
async deleteDeployment(projectId: string, appId: string) {
|
|
const existingDeployment = await this.getDeployment(projectId, appId);
|
|
if (!existingDeployment) {
|
|
return;
|
|
}
|
|
return k3s.apps.deleteNamespacedDeployment(appId, projectId);
|
|
}
|
|
|
|
async deleteService(projectId: string, appId: string) {
|
|
const existingService = await this.getService(projectId, appId);
|
|
if (!existingService) {
|
|
return;
|
|
}
|
|
return k3s.core.deleteNamespacedService(StringUtils.toServiceName(appId), projectId);
|
|
}
|
|
|
|
|
|
async getService(projectId: string, appId: string) {
|
|
const allServices = await k3s.core.listNamespacedService(projectId);
|
|
if (allServices.body.items.some((item) => item.metadata?.name === StringUtils.toServiceName(appId))) {
|
|
const res = await k3s.core.readNamespacedService(StringUtils.toServiceName(appId), projectId);
|
|
return res.body;
|
|
}
|
|
}
|
|
|
|
async createOrUpdateService(app: AppExtendedModel) {
|
|
const existingService = await this.getService(app.projectId, app.id);
|
|
// port configuration with removed duplicates
|
|
const ports: {
|
|
port: number;
|
|
targetPort: number;
|
|
}[] = [
|
|
...app.appDomains.map((domain) => ({
|
|
port: domain.port,
|
|
targetPort: domain.port
|
|
})),
|
|
{
|
|
port: app.defaultPort,
|
|
targetPort: app.defaultPort
|
|
}
|
|
].filter((port, index, self) =>
|
|
index === self.findIndex((t) => (t.port === port.port && t.targetPort === port.targetPort)));
|
|
|
|
const body = {
|
|
metadata: {
|
|
name: StringUtils.toServiceName(app.id)
|
|
},
|
|
spec: {
|
|
selector: {
|
|
app: app.id
|
|
},
|
|
ports: ports
|
|
}
|
|
};
|
|
if (existingService) {
|
|
await k3s.core.replaceNamespacedService(StringUtils.toServiceName(app.id), app.projectId, body);
|
|
} else {
|
|
await k3s.core.createNamespacedService(app.projectId, body);
|
|
}
|
|
await this.createOrUpdateIngress(app);
|
|
|
|
}
|
|
|
|
async createDeployment(app: AppExtendedModel, buildJobName?: string) {
|
|
await this.createNamespaceIfNotExists(app.projectId);
|
|
|
|
const envVars = app.envVars
|
|
? app.envVars.split(',').map(env => {
|
|
const [name, value] = env.split('=');
|
|
return { name, value };
|
|
})
|
|
: [];
|
|
|
|
const existingDeployment = await this.getDeployment(app.projectId, app.id);
|
|
const body: V1Deployment = {
|
|
metadata: {
|
|
name: app.id,
|
|
|
|
},
|
|
spec: {
|
|
// strategy: 'rollingUpdate',
|
|
replicas: app.replicas,
|
|
selector: {
|
|
matchLabels: {
|
|
app: app.id
|
|
}
|
|
},
|
|
template: {
|
|
metadata: {
|
|
labels: {
|
|
app: app.id
|
|
},
|
|
annotations: {
|
|
deploymentTimestamp: new Date().getTime() + "",
|
|
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
|
|
}
|
|
},
|
|
spec: {
|
|
containers: [
|
|
{
|
|
name: app.id,
|
|
image: !!buildJobName ? buildService.createContainerRegistryUrlForAppId(app.id) : app.containerImageSource as string,
|
|
imagePullPolicy: 'Always',
|
|
...(envVars.length > 0 ? { env: envVars } : {}),
|
|
/*ports: [
|
|
{
|
|
containerPort: app.port
|
|
}
|
|
]*/
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
};
|
|
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 {
|
|
const res = await k3s.apps.createNamespacedDeployment(app.projectId, body);
|
|
}
|
|
await this.createOrUpdateService(app);
|
|
}
|
|
|
|
async setReplicasForDeployment(projectId: string, appId: string, replicas: number) {
|
|
const existingDeployment = await this.getDeployment(projectId, appId);
|
|
if (!existingDeployment) {
|
|
throw new ServiceException("This app has not been deployed yet. Please deploy it first.");
|
|
}
|
|
existingDeployment.spec!.replicas = replicas;
|
|
return k3s.apps.replaceNamespacedDeployment(appId, projectId, existingDeployment);
|
|
}
|
|
|
|
async createNamespaceIfNotExists(namespace: string) {
|
|
const existingNamespaces = await this.getNamespaces();
|
|
if (existingNamespaces.includes(namespace)) {
|
|
return;
|
|
}
|
|
await k3s.core.createNamespace({
|
|
metadata: {
|
|
name: namespace
|
|
}
|
|
});
|
|
}
|
|
|
|
async deleteNamespace(namespace: string) {
|
|
const nameSpaces = await this.getNamespaces();
|
|
if (nameSpaces.includes(namespace)) {
|
|
await k3s.core.deleteNamespace(namespace);
|
|
}
|
|
}
|
|
|
|
async getPodsForApp(projectId: string, appId: string) {
|
|
const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
|
|
return res.body.items.map((item) => ({
|
|
podName: item.metadata?.name!,
|
|
containerName: item.spec?.containers?.[0].name!
|
|
})).filter((item) => !!item.podName && !!item.containerName) as PodsInfoModel[];
|
|
}
|
|
|
|
async getPodByName(projectId: string, podName: string) {
|
|
const res = await k3s.core.readNamespacedPod(podName, projectId);
|
|
return {
|
|
podName: res.body.metadata?.name!,
|
|
containerName: res.body.spec?.containers?.[0].name!
|
|
} as PodsInfoModel;
|
|
}
|
|
|
|
async getIngress(projectId: string, appId: string) {
|
|
const res = await k3s.network.listIngressClass(projectId);
|
|
return res.body.items.find((item) => item.metadata?.name === `ingress-${appId}`);
|
|
}
|
|
|
|
async createOrUpdateIngress(app: AppExtendedModel) {
|
|
//const existingIngress = await this.getIngress(app.projectId, app.id);
|
|
const ingressDefinition: V1Ingress = {
|
|
apiVersion: 'networking.k8s.io/v1',
|
|
kind: 'Ingress',
|
|
metadata: {
|
|
name: `ingress-${app.id}`,
|
|
namespace: app.projectId,
|
|
annotations: {
|
|
'cert-manager.io/cluster-issuer': 'letsencrypt-staging',
|
|
},
|
|
},
|
|
spec: {
|
|
ingressClassName: 'traefik',
|
|
rules: [
|
|
{
|
|
host: `shelby.meyer-net.ch`,
|
|
http: {
|
|
paths: [
|
|
{
|
|
path: '/',
|
|
pathType: 'Prefix',
|
|
backend: {
|
|
service: {
|
|
name: StringUtils.toServiceName(app.id),
|
|
port: {
|
|
number: app.defaultPort,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
tls: [
|
|
{
|
|
hosts: [`shelby.meyer-net.ch`],
|
|
secretName: `secret-tls-${app.id}`,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
await k3s.network.createNamespacedIngress(app.projectId, ingressDefinition);
|
|
/*if (existingIngress) {
|
|
await k3s.network.replaceNamespacedIngress(`ingress-${app.id}`, app.projectId, ingressDefinition);
|
|
} else {
|
|
await k3s.network.createNamespacedIngress(app.projectId, ingressDefinition);
|
|
}*/
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
export default deploymentService;
|