UNPKG

@topgroup/diginext

Version:

A BUILD SERVER & CLI to deploy apps to any Kubernetes clusters.

484 lines (483 loc) 25.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateDeploymentV2 = void 0; const log_1 = require("diginext-utils/dist/xconsole/log"); const fs = __importStar(require("fs")); const js_yaml_1 = __importDefault(require("js-yaml")); const lodash_1 = require("lodash"); const config_1 = require("../../config/config"); const const_1 = require("../../config/const"); const plugins_1 = require("../../plugins"); const env_var_1 = require("../../plugins/env-var"); const slug_1 = require("../../plugins/slug"); const app_helper_1 = require("../apps/app-helper"); const build_1 = require("../build"); const k8s_1 = __importDefault(require("../k8s")); const image_pull_secret_1 = require("../k8s/image-pull-secret"); const generate_deployment_name_1 = __importDefault(require("./generate-deployment-name")); const generate_domain_1 = require("./generate-domain"); const nginxBlockedPaths = "location ~ /.git { deny all; return 403; }"; const generateDeploymentV2 = async (params) => { var _a, _b, _c, _d; const { appSlug, buildTag, buildImage, env = "dev", port, skipPrerelease = false, username, workspace, appConfig, registry: inputRegistry, } = params; // validate inputs if (!appSlug) throw new Error(`Unable to generate YAML, app's slug is required.`); if (!buildTag) throw new Error(`Unable to generate YAML, build number is required.`); // const { DB } = await import("../../modules/api/DB"); const { AppService } = await Promise.resolve().then(() => __importStar(require("../../services"))); const { ClusterService } = await Promise.resolve().then(() => __importStar(require("../../services"))); const { UserService } = await Promise.resolve().then(() => __importStar(require("../../services"))); const { ContainerRegistryService } = await Promise.resolve().then(() => __importStar(require("../../services"))); const appSvc = new AppService(); const clusterSvc = new ClusterService(); const userSvc = new UserService(); const containerRegistrySvc = new ContainerRegistryService(); const app = await appSvc.findOne({ slug: appSlug }, { populate: ["project", "workspace", "owner"] }); const currentAppConfig = appConfig || (0, app_helper_1.getAppConfigFromApp)(app); let appOwner = app.ownerSlug; if (!appOwner) { appOwner = app.owner.slug; await appSvc .updateOne({ slug: appSlug }, { ownerSlug: appOwner }) .catch((e) => console.error(`Unable to update "appOwner" to "${appSlug}" app.`)); } let projectSlug = currentAppConfig.project; if (!projectSlug) throw new Error(`Unable to generate YAML, a "project" (slug) param in "${env}" deploy environment of "${appSlug}" is required.`); // DEFINE DEPLOYMENT PARTS: if (params.isDebugging) console.log("generateDeploymentV2() > buildTag :>> ", buildTag); const deployEnvironmentConfig = currentAppConfig.deployEnvironment[env]; let deploymentName = await (0, generate_deployment_name_1.default)(app); let nsName = deployEnvironmentConfig.namespace || `${projectSlug}-${env}`; let ingName = deploymentName; let svcName = deploymentName; let mainAppName = deploymentName; let buildNumber = (_a = app.buildNumber) !== null && _a !== void 0 ? _a : 1; let appVersion = deploymentName + "-" + buildNumber; let basePath = (_b = deployEnvironmentConfig.basePath) !== null && _b !== void 0 ? _b : ""; const healthzPath = deployEnvironmentConfig.healthzPath; // Overwrite exposed port if (typeof port !== "undefined") deployEnvironmentConfig.port = port; if (typeof deployEnvironmentConfig.port === "undefined") throw new Error(`Unable to generate deployment YAML, port is required.`); // get destination cluster const clusterSlug = deployEnvironmentConfig.cluster; let cluster = await clusterSvc.findOne({ slug: clusterSlug }, { subpath: "/all", populate: ["owner"] }); if (!cluster) { throw new Error(`Cannot find any clusters with short name as "${clusterSlug}", please contact your admin or create a new one.`); } // Authenticate with the cluster await k8s_1.default.authCluster(cluster, { ownership: { owner: cluster.owner, workspace } }); const { contextName: context } = cluster; // Prepare for building docker image let IMAGE_NAME = buildImage ? `${buildImage}:${buildTag}` : undefined; if (!IMAGE_NAME && deployEnvironmentConfig.imageURL) IMAGE_NAME = `${deployEnvironmentConfig.imageURL}:${buildTag}`; if (!IMAGE_NAME) throw new Error(`Unable to generate deployment YAML, image name (image url + tag) is required.`); deployEnvironmentConfig.imageURL = buildImage; let domains = deployEnvironmentConfig.domains; let replicas = (_c = deployEnvironmentConfig.replicas) !== null && _c !== void 0 ? _c : 1; // if no domains, generate a default DIGINEXT domain: if (!domains) { const user = await userSvc.findOne({ slug: username }); const { subdomain } = await (0, build_1.diginextDomainName)(env, projectSlug, appSlug); const { status, domain: generatedDomain, messages, } = await (0, generate_domain_1.generateDomains)({ user, workspace, recordName: subdomain, clusterSlug: clusterSlug, // isDebugging: true, }); if (!status) throw new Error(messages.join("\n")); deployEnvironmentConfig.domains = domains = [generatedDomain]; deployEnvironmentConfig.ssl = "letsencrypt"; } const BASE_URL = domains && domains.length > 0 ? `https://${domains[0]}` : `http://${svcName}.${nsName}.svc.cluster.local`; // get container registry & create "imagePullSecret" in the target cluster let registry = inputRegistry || (deployEnvironmentConfig.registry ? await containerRegistrySvc.findOne({ slug: deployEnvironmentConfig.registry }, { subpath: "/all" }) : undefined); if (!registry) throw new Error(`Container registries not found, please contact your admin or create a new one.`); deployEnvironmentConfig.registry = registry.slug; if (!registry.imagePullSecret) { const imagePullSecret = await (0, image_pull_secret_1.createImagePullSecretsInNamespace)(appSlug, env, clusterSlug, nsName); await containerRegistrySvc.updateOne({ _id: registry._id }, { imagePullSecret }); } // console.log("registry :>> ", registry); // get registry secret as image pulling secret: const { imagePullSecret } = registry; // * [NEW TACTIC] Fetch ENV variables from database: const deployEnvironment = app.deployEnvironment[env] || {}; // console.log("generate deployment > deployEnvironment :>> ", deployEnvironment); let containerEnvs = deployEnvironment.envVars || []; // console.log("[1] containerEnvs :>> ", containerEnvs); // FIXME: magic? if ((0, lodash_1.isObject)(containerEnvs)) containerEnvs = Object.entries(containerEnvs).map(([key, val]) => val); // kubernetes YAML only accept string as env variable value containerEnvs = (0, env_var_1.formatEnvVars)(containerEnvs); // console.log("[2] containerEnvs :>> ", containerEnvs); // Should inherit the "Ingress" config from the previous deployment? let previousDeployment, previousIng = { metadata: { annotations: {} } }; if (deployEnvironmentConfig.shouldInherit && deployEnvironment && deployEnvironment.deploymentYaml) { try { previousDeployment = js_yaml_1.default.loadAll(deployEnvironment.deploymentYaml); previousDeployment.map((doc, index) => { if (doc && doc.kind == "Ingress") previousIng = doc; }); } catch (e) { (0, log_1.logWarn)(`Unable to parse previous deployment YAML:`, e, `\n=> Previous YAML:\n`, deployEnvironment.deploymentYaml); } } // assign labels const labels = {}; labels.workspace = workspace.slug; labels.owner = appOwner; labels["updated-by"] = username; labels.project = projectSlug; labels.app = mainAppName; labels["main-app"] = mainAppName; labels["app-version"] = appVersion; labels["deploy-strategy"] = "v2"; // get available ingress class const ingressClasses = (await k8s_1.default.getIngressClasses({ context })) || []; // write namespace.[env].yaml if (!fs.existsSync(const_1.NAMESPACE_TEMPLATE_PATH)) throw new Error(`Namespace template not found: "${const_1.NAMESPACE_TEMPLATE_PATH}"`); let namespaceContent = fs.readFileSync(const_1.NAMESPACE_TEMPLATE_PATH, "utf8"); let namespaceObject = (js_yaml_1.default.load(namespaceContent) || {}); if (params.isDebugging) console.log("Generate deployment > namespace > template YAML :>> ", namespaceContent); namespaceObject.metadata.name = nsName; namespaceObject.metadata.labels = ((_d = namespaceObject.metadata) === null || _d === void 0 ? void 0 : _d.labels) || {}; namespaceObject.metadata.labels.project = projectSlug.toLowerCase(); namespaceObject.metadata.labels.owner = appOwner.toLowerCase(); namespaceObject.metadata.labels.workspace = workspace.slug.toLowerCase(); namespaceContent = (0, plugins_1.objectToDeploymentYaml)(namespaceObject); if (params.isDebugging) console.log("Generate deployment > namespace > final YAML :>> ", namespaceContent); // write deployment.[env].yaml (ing, svc, deployment) let deploymentContent = fs.readFileSync(const_1.FULL_DEPLOYMENT_TEMPLATE_PATH, "utf8"); let deploymentCfg = js_yaml_1.default.loadAll(deploymentContent); // console.log("app.deployEnvironment :>> ", app.deployEnvironment); // console.log("app.deployEnvironment[env].volumes :>> ", app.deployEnvironment[env].volumes); if (deploymentCfg.length) { deploymentCfg.forEach((doc, index) => { // Make sure all objects stay in the same namespace: if (doc && doc.metadata && doc.metadata.namespace) doc.metadata.namespace = nsName; // INGRESS if (doc && doc.kind == "Ingress") { if (domains.length > 0) { const ingCfg = doc; if (ingCfg.metadata) ingCfg.metadata = {}; ingCfg.metadata.name = ingName; ingCfg.metadata.namespace = nsName; // inherit config from previous deployment if (deployEnvironmentConfig.shouldInherit) { ingCfg.metadata.annotations = { ...previousIng === null || previousIng === void 0 ? void 0 : previousIng.metadata.annotations, ...ingCfg.metadata.annotations, }; } if (!ingCfg.metadata.annotations) ingCfg.metadata.annotations = {}; if (deployEnvironmentConfig.ssl == "letsencrypt") { ingCfg.metadata.annotations["cert-manager.io/cluster-issuer"] = "letsencrypt-prod"; } else { ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"; } // ingress class const ingressClass = ingressClasses[0] && ingressClasses[0].metadata ? ingressClasses[0].metadata.name : undefined; if (ingressClass) { // ! OLD -> DEPRECATED!!! delete ingCfg.metadata.annotations["kubernetes.io/ingress.class"]; // ! NEW -> WORKING!!! ingCfg.spec.ingressClassName = ingressClass; } // block some specific paths ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/server-snippet"] = nginxBlockedPaths; // limit file upload & body size ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/proxy-body-size"] = "100m"; // limit requests per minute (DEV ONLY) if (ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/limit-rpm"]) delete ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/limit-rpm"]; if (env !== "prod") ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/limit-rps"] = `200`; // labels doc.metadata.labels = labels; doc.metadata.labels.phase = "live"; // redirect if (deployEnvironmentConfig.redirect) { if (!domains.length) { (0, log_1.logWarn)(`Can't redirect to primary domain if there are no domains in "${env}" deploy environment`); } else if (domains.length == 1) { (0, log_1.logWarn)(`Can't redirect to primary domain if there is only 1 domain in "${env}" deploy environment`); } else { const otherDomains = domains.slice(1); let redirectStr = ""; otherDomains.map((domain) => { redirectStr += `if ($host = '${domain}') {\n`; redirectStr += ` rewrite / https://${domains[0]}$request_uri redirect;\n`; redirectStr += `}\n`; }); ingCfg.metadata.annotations["nginx.ingress.kubernetes.io/configuration-snippet"] = redirectStr; } } ingCfg.spec.tls = []; ingCfg.spec.rules = []; domains.map((domain) => { // tls ingCfg.spec.tls.push({ hosts: [domain], secretName: deployEnvironmentConfig.tlsSecret || `tls-${(0, slug_1.makeSlug)(domain)}`, }); // rules ingCfg.spec.rules.push({ host: domain, http: { paths: [ { path: "/" + basePath, pathType: "Prefix", backend: { service: { name: svcName, port: { number: deployEnvironmentConfig.port } }, }, }, ], }, }); }); // delete SSL config if have to: if (deployEnvironmentConfig.ssl == "none") { try { delete ingCfg.metadata.annotations["cert-manager.io/cluster-issuer"]; delete ingCfg.spec.tls; } catch (e) { } } } else { delete deploymentCfg[index]; doc = null; } } if (doc && doc.kind == "Service") { doc.metadata.name = svcName; // labels doc.metadata.labels = labels; doc.metadata.labels.phase = "live"; doc.spec.selector.app = mainAppName; // Routing traffic to the same pod base on ClientIP doc.spec.sessionAffinity = "ClientIP"; doc.spec.ports = [{ port: deployEnvironmentConfig.port, targetPort: deployEnvironmentConfig.port }]; } if (doc && doc.kind == "Deployment") { // if (env == "dev") { // // development environment // doc.spec.template.spec.containers[0].resources = {}; // } else { // // canary, production, staging,... // doc.spec.template.spec.containers[0].resources = getContainerResourceBySize(deployEnvironmentConfig.size || "1x"); // } doc.spec.template.spec.containers[0].resources = (0, config_1.getContainerResource)(deployEnvironmentConfig.cpu, deployEnvironmentConfig.memory); // minimum number of seconds for which a newly created Pod should be ready without any of its containers crashing doc.spec.minReadySeconds = 10; // * Add roll out strategy -> Rolling Update doc.spec.strategy = { type: "RollingUpdate", rollingUpdate: { maxSurge: 1, maxUnavailable: 1, }, }; // container replicas doc.spec.replicas = replicas; // doc.metadata.name = appName; doc.metadata.name = mainAppName; // deployment's labels doc.metadata.labels = labels; doc.metadata.labels.phase = "live"; // pod's labels doc.spec.template.metadata.labels = labels; doc.spec.template.metadata.labels.phase = "live"; // doc.spec.selector.matchLabels.app = appName; doc.spec.selector.matchLabels.app = mainAppName; // Inject "imagePullSecrets" to pull image from the container registry if (imagePullSecret) doc.spec.template.spec.imagePullSecrets = [{ name: imagePullSecret.name }]; // container // doc.spec.template.spec.containers[0].name = appName; doc.spec.template.spec.containers[0].name = mainAppName; doc.spec.template.spec.containers[0].image = IMAGE_NAME; doc.spec.template.spec.containers[0].env = containerEnvs; // NOTE: PORT 80 có thể không sử dụng được trên cluster của Digital Ocean doc.spec.template.spec.containers[0].ports = [{ containerPort: (0, lodash_1.toNumber)(deployEnvironmentConfig.port) }]; // readinginessProbe & livenessProbe if (healthzPath) { // RUNNING: Sometimes, applications are temporarily unable to serve traffic doc.spec.template.spec.containers[0].readinessProbe = { httpGet: { path: healthzPath, port: deployEnvironmentConfig.healthzPort || (0, lodash_1.toNumber)(deployEnvironmentConfig.port), }, initialDelaySeconds: 30, timeoutSeconds: 2, periodSeconds: 15, successThreshold: 1, failureThreshold: 3, }; // STARTUP: The application is considered unhealthy after a certain number of consecutive failures doc.spec.template.spec.containers[0].livenessProbe = { httpGet: { path: healthzPath, port: deployEnvironmentConfig.healthzPort || (0, lodash_1.toNumber)(deployEnvironmentConfig.port), }, initialDelaySeconds: 30, timeoutSeconds: 2, periodSeconds: 10, successThreshold: 1, failureThreshold: 30, // check 30 lần fail x 10s = 300s (5 phút) }; } // add persistent volumes (IF ANY) if (app.deployEnvironment[env].volumes && app.deployEnvironment[env].volumes.length > 0) { const { volumes } = app.deployEnvironment[env]; let nodeName = volumes[0].node; // persistent volume claim doc.spec.template.spec.volumes = volumes.map((vol) => { switch (vol.type) { case "pvc": return { name: vol.name, persistentVolumeClaim: { claimName: vol.name } }; case "host-path": default: return { name: vol.name, hostPath: { path: vol.hostPath, type: "DirectoryOrCreate" } }; } }); // mount to container doc.spec.template.spec.containers[0].volumeMounts = volumes.map((vol) => ({ name: vol.name, mountPath: vol.mountPath, })); // "nodeAffinity" -> to make sure pods are scheduled to the same node with the persistent volume doc.spec.template.spec.affinity = { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [ { matchExpressions: [ { key: "kubernetes.io/hostname", operator: "In", values: [nodeName], }, ], }, ], }, }, }; } } }); } else { throw new Error("YAML deployment template is incorrect"); } // add persistent volumes if needed if (app.deployEnvironment[env].volumes && app.deployEnvironment[env].volumes.length > 0) { const { volumes } = app.deployEnvironment[env]; // get storage class name const allStorageClasses = await k8s_1.default.getAllStorageClasses({ context: cluster.contextName }); if (!allStorageClasses || allStorageClasses.length === 0) throw new Error(`Unable to create volume, this cluster doesn't have any storage class.`); const storageClass = allStorageClasses[0].metadata.name; volumes.forEach((vol) => { // persistent volume claim const persistentVolumeClaim = { apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: vol.name, namespace: nsName, labels, }, spec: { storageClassName: storageClass, resources: { requests: { storage: vol.size }, }, accessModes: ["ReadWriteOnce"], }, }; deploymentCfg.push(persistentVolumeClaim); }); } deploymentContent = (0, plugins_1.objectToDeploymentYaml)(deploymentCfg); // Get endpoint of the application (last domain, if any): const lastDomain = domains ? domains[domains.length - 1] : undefined; let endpoint = lastDomain ? `https://${lastDomain}/${basePath}` : undefined; // update deploy environment // const updatedApp = await DB.updateOne("app", { _id: app._id }, { [`deployEnvironment.${env}`]: deployEnvironmentConfig }); return { envVars: containerEnvs, // namespace namespaceContent, namespaceObject, // deployment (ingress, service, pods,...) deploymentName, deployEnvironment: deployEnvironmentConfig, deploymentContent, deploymentCfg, // prerelease (ingress, service, pods,...) // prereleaseYamlObject, // prereleaseDeploymentContent, // prereleaseUrl, // accessibility buildTag, buildNumber, IMAGE_NAME, endpoint, }; }; exports.generateDeploymentV2 = generateDeploymentV2;