@topgroup/diginext
Version:
A BUILD SERVER & CLI to deploy apps to any Kubernetes clusters.
552 lines (551 loc) • 29.7 kB
JavaScript
;
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;