UNPKG

@topgroup/diginext

Version:

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

552 lines (551 loc) 29.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.generateDeployment = 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 = __importStar(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 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 generateDeployment = async (params) => { var _a, _b, _c, _d; const { appSlug, buildTag, env = "dev", skipPrerelease = false, username, user, workspace, appConfig } = 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 Promise.resolve().then(() => __importStar(require("../../modules/api/DB"))); const app = await DB.findOne("app", { slug: appSlug }, { populate: ["project", "workspace", "owner"] }); const currentAppConfig = appConfig || (0, app_helper_1.getAppConfigFromApp)(app); // DEFINE DEPLOYMENT PARTS: if (params.isDebugging) console.log("generateDeployment() > buildTag :>> ", buildTag); const deployEnvironmentConfig = currentAppConfig.deployEnvironment[env]; const registrySlug = deployEnvironmentConfig.registry; // let deploymentName = project + "-" + appSlug.toLowerCase(); let deploymentName = await (0, generate_deployment_name_1.default)(app); let nsName = deployEnvironmentConfig.namespace; let ingName = deploymentName; let svcName = deploymentName; let mainAppName = deploymentName; let appName = deploymentName + "-" + ((_a = app.buildNumber) !== null && _a !== void 0 ? _a : 1); let basePath = (_b = deployEnvironmentConfig.basePath) !== null && _b !== void 0 ? _b : ""; // Prepare for building docker image const { imageURL } = deployEnvironmentConfig; // TODO: Replace BUILD_NUMBER so it can work with Skaffold const IMAGE_NAME = `${imageURL}:${buildTag}`; let projectSlug = currentAppConfig.project; let domains = deployEnvironmentConfig.domains; let replicas = (_c = deployEnvironmentConfig.replicas) !== null && _c !== void 0 ? _c : 1; const BASE_URL = domains && domains.length > 0 ? `https://${domains[0]}` : `http://${svcName}.${nsName}.svc.cluster.local`; const clusterSlug = deployEnvironmentConfig.cluster; // get container registry let registry = await DB.findOne("registry", { slug: registrySlug }); if (!registry) { throw new Error(`Cannot find any container registries with slug as "${registrySlug}", please contact your admin or create a new one.`); } if (!registry.imagePullSecret) { const imagePullSecret = await (0, image_pull_secret_1.createImagePullSecretsInNamespace)(appSlug, env, clusterSlug, nsName); [registry] = await DB.update("registry", { _id: registry._id }, { imagePullSecret }); } // console.log("registry :>> ", registry); // get destination cluster let cluster = await DB.findOne("cluster", { slug: clusterSlug }, { subpath: "/all" }); if (!cluster) { throw new Error(`Cannot find any clusters with short name as "${clusterSlug}", please contact your admin or create a new one.`); } const { contextName: context } = cluster; // get registry secret as image pulling secret: const { imagePullSecret } = registry; // prerelease: const prereleaseSubdomainName = `${appSlug.toLowerCase()}.prerelease`; let prereleaseIngName = `prerelease-${ingName}`; let prereleaseSvcName = `prerelease-${svcName}`; let prereleaseAppName = `prerelease-${appName}`; let prereleaseIngressDoc, prereleaseSvcDoc, prereleaseDeployDoc; let prereleaseDomain; // Setup a domain for prerelease if (env == "prod") { const { status, domain, messages } = await (0, generate_domain_1.generateDomains)({ user, workspace, primaryDomain: const_1.DIGINEXT_DOMAIN, recordName: prereleaseSubdomainName, clusterSlug: deployEnvironmentConfig.cluster, }); if (status === 0) throw new Error(`Unable to create PRE-RELEASE domain "${domain}" due to "${messages.join(". ")}"`); prereleaseDomain = domain; } if (env === "prod") (0, log_1.log)({ prereleaseDomain }); // * [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); // prerelease ENV variables (is the same with PROD ENV variables, except the domains/origins if any): let prereleaseEnvs = []; if (env === "prod" && !(0, lodash_1.isEmpty)(domains)) { prereleaseEnvs = containerEnvs.map(({ value, ...envVar }) => { // DO NOT replace origin domain of PRERELEASE env: if (skipPrerelease) return { value, ...envVar }; let curValue = value || ""; if (curValue.indexOf(domains[0]) > -1) { // replace all production domains with PRERELEASE domains curValue = curValue.replace(new RegExp(domains[0], "gi"), prereleaseDomain); } return { ...envVar, value: curValue }; }); } // console.log("[3] prereleaseEnvs :>> ", prereleaseEnvs); // 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); } } // 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 = username.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 (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 let ingressClass = ""; if (deployEnvironmentConfig.ingress && (ingressClasses.map((ingClass) => { var _a; return (_a = ingClass === null || ingClass === void 0 ? void 0 : ingClass.metadata) === null || _a === void 0 ? void 0 : _a.name; }) || []).includes(deployEnvironmentConfig.ingress)) { ingressClass = deployEnvironmentConfig.ingress; } else { ingressClass = ingressClasses[0] && ingressClasses[0].metadata ? ingressClasses[0].metadata.name : undefined; } if (ingressClass) { // OLD // ingCfg.metadata.annotations["kubernetes.io/ingress.class"] = ingressClass; // NEW 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 if (!doc.metadata.labels) doc.metadata.labels = {}; doc.metadata.labels.workspace = workspace.slug; doc.metadata.labels["updated-by"] = username; doc.metadata.labels.project = projectSlug; doc.metadata.labels.app = appName; doc.metadata.labels["main-app"] = mainAppName; 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-secret-letsencrypt-${(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) { } } // pre-release prereleaseIngressDoc = lodash_1.default.cloneDeep(doc); prereleaseIngressDoc.metadata.name = prereleaseIngName; prereleaseIngressDoc.metadata.namespace = nsName; prereleaseIngressDoc.metadata.annotations["nginx.ingress.kubernetes.io/configuration-snippet"] = ""; prereleaseIngressDoc.metadata.annotations["cert-manager.io/cluster-issuer"] = "letsencrypt-prod"; // block some specific paths prereleaseIngressDoc.metadata.annotations["nginx.ingress.kubernetes.io/server-snippet"] = nginxBlockedPaths; prereleaseIngressDoc.spec.tls = [ { hosts: [prereleaseDomain], secretName: `secret-${lodash_1.default.kebabCase(prereleaseDomain)}`, }, ]; prereleaseIngressDoc.spec.rules = [ { host: prereleaseDomain, http: { paths: [ { path: "/" + basePath, pathType: "Prefix", backend: { service: { name: prereleaseSvcName, port: { number: deployEnvironmentConfig.port } }, }, }, ], }, }, ]; } else { delete deploymentCfg[index]; doc = null; } } if (doc && doc.kind == "Service") { doc.metadata.name = svcName; if (!doc.metadata.labels) doc.metadata.labels = {}; doc.metadata.labels.workspace = workspace.slug; doc.metadata.labels["updated-by"] = username; doc.metadata.labels.project = projectSlug; doc.metadata.labels.app = appName; doc.metadata.labels["main-app"] = mainAppName; doc.metadata.labels.phase = "live"; doc.spec.selector.app = appName; // Routing traffic to the same pod base on ClientIP doc.spec.sessionAffinity = "ClientIP"; doc.spec.ports = [{ port: deployEnvironmentConfig.port, targetPort: deployEnvironmentConfig.port }]; // clone svc to prerelease: prereleaseSvcDoc = lodash_1.default.cloneDeep(doc); prereleaseSvcDoc.metadata.name = prereleaseSvcName; prereleaseSvcDoc.metadata.namespace = nsName; prereleaseSvcDoc.metadata.labels["main-app"] = mainAppName; prereleaseSvcDoc.metadata.labels.app = appName; prereleaseSvcDoc.metadata.labels.phase = "prerelease"; prereleaseSvcDoc.spec.selector.app = prereleaseAppName; } 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 = (0, config_1.getContainerResourceBySize)(deployEnvironmentConfig.size || "1x"); // * 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; // deployment's labels if (!doc.metadata.labels) doc.metadata.labels = {}; doc.metadata.labels.workspace = workspace.slug; doc.metadata.labels["updated-by"] = username; doc.metadata.labels.project = projectSlug; doc.metadata.labels.app = appName; doc.metadata.labels["main-app"] = mainAppName; doc.metadata.labels.phase = "live"; // pod's labels doc.spec.template.metadata.labels.workspace = workspace.slug; doc.spec.template.metadata.labels["updated-by"] = username; doc.spec.template.metadata.labels.project = projectSlug; doc.spec.template.metadata.labels.app = appName; doc.spec.template.metadata.labels["main-app"] = mainAppName; doc.spec.template.metadata.labels.phase = "live"; doc.spec.selector.matchLabels.app = appName; // container doc.spec.template.spec.containers[0].name = appName; // Inject "imagePullSecrets" to pull image from the container registry doc.spec.template.spec.imagePullSecrets = [{ name: imagePullSecret.name }]; doc.spec.template.spec.containers[0].image = IMAGE_NAME; doc.spec.template.spec.containers[0].env = containerEnvs; // CAUTION: PORT 80 sẽ 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 // RUNNING: Sometimes, applications are temporarily unable to serve traffic doc.spec.template.spec.containers[0].readinessProbe = { httpGet: { path: "/", port: (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: "/", port: (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 (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], }, ], }, ], }, }, }; } // prerelease's deployment: prereleaseDeployDoc = lodash_1.default.cloneDeep(doc); prereleaseDeployDoc.metadata.namespace = nsName; prereleaseDeployDoc.metadata.name = prereleaseAppName; prereleaseDeployDoc.metadata.labels.phase = "prerelease"; prereleaseDeployDoc.metadata.labels["main-app"] = mainAppName; prereleaseDeployDoc.metadata.labels.app = prereleaseAppName; prereleaseDeployDoc.metadata.labels.project = projectSlug; prereleaseDeployDoc.metadata.labels.workspace = workspace.slug; prereleaseDeployDoc.metadata.labels["updated-by"] = username; prereleaseDeployDoc.spec.replicas = 1; prereleaseDeployDoc.spec.template.metadata.labels.phase = "prerelease"; prereleaseDeployDoc.spec.template.metadata.labels["main-app"] = mainAppName; prereleaseDeployDoc.spec.template.metadata.labels.app = prereleaseAppName; prereleaseDeployDoc.spec.template.metadata.labels.project = projectSlug; prereleaseDeployDoc.spec.template.metadata.labels.workspace = workspace.slug; prereleaseDeployDoc.spec.template.metadata.labels["updated-by"] = username; prereleaseDeployDoc.spec.template.spec.containers[0].image = IMAGE_NAME; prereleaseDeployDoc.spec.template.spec.containers[0].env = prereleaseEnvs; prereleaseDeployDoc.spec.template.spec.containers[0].resources = {}; // prerelease's app selector prereleaseDeployDoc.spec.selector.matchLabels.app = prereleaseAppName; // ! no need roll out strategy for prerelease: delete prereleaseDeployDoc.spec.strategy; } }); } 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; // assign labels const labels = {}; labels.workspace = workspace.slug; labels["updated-by"] = username; labels.project = projectSlug; labels.app = appName; labels["main-app"] = mainAppName; 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); /** * PRE-RELEASE DEPLOYMENT: */ let prereleaseYamlObject = [prereleaseIngressDoc, prereleaseSvcDoc, prereleaseDeployDoc]; let prereleaseDeploymentContent = (0, plugins_1.objectToDeploymentYaml)(prereleaseYamlObject); // End point của ứng dụng: let endpoint = `https://${domains[0]}/${basePath}`; const prereleaseUrl = `https://${prereleaseDomain}/${basePath}`; return { envVars: containerEnvs, // namespace namespaceContent, namespaceObject, // deployment (ingress, service, pods,...) deploymentContent, deploymentCfg, // prerelease (ingress, service, pods,...) prereleaseYamlObject, prereleaseDeploymentContent, // accessibility buildTag: buildTag, IMAGE_NAME, endpoint, prereleaseUrl, }; }; exports.generateDeployment = generateDeployment;