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