@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
222 lines • 8.19 kB
JavaScript
import { logger } from '@appsemble/node-utils';
import { ApiException, AppsV1Api, CoreV1Api, KubeConfig, } from '@kubernetes/client-node';
export const maxCPU = process.env.MAX_CONTAINER_CPU ?? 3;
// In gigabytes
export const maxMemoryGi = process.env.MAX_CONTAINER_MEMORY ?? 3;
export const appIdLabel = 'appId';
export const resourceDefaults = { memory: '128Mi', cpu: '0.1' };
export function getContainerNamespace() {
return `companion-containers-${process.env.SERVICE_NAME ?? 'appsemble'}`;
}
export function formatServiceName(containerName, appName, appId) {
const serviceName = `${containerName}-${appName}-${appId}`.replaceAll(' ', '-').toLowerCase();
return serviceName;
}
export function formatSecretName(appName, appId) {
return `${appName}-${appId}`.toLocaleLowerCase().replaceAll(' ', '-');
}
export function handleKubernetesError(error) {
if (error instanceof ApiException) {
if (error.code) {
logger.warn(`Kubernetes error with status code ${error.code}:`);
}
logger.warn(error.body);
return;
}
logger.error(error);
}
function sleep(timeout) {
return new Promise((resolve) => {
setTimeout(resolve, timeout);
});
}
function isTransientKubernetesError(error) {
if (!(error instanceof ApiException)) {
return true;
}
return (typeof error.code === 'number' &&
(error.code === 408 || error.code === 429 || error.code >= 500));
}
async function withKubernetesRetry(description, operation) {
const attempts = Number(process.env.KUBERNETES_REQUEST_RETRIES ?? 3);
const delay = Number(process.env.KUBERNETES_RETRY_DELAY_MS ?? 1000);
for (let attempt = 1;; attempt += 1) {
try {
return await operation();
}
catch (error) {
if (attempt >= attempts || !isTransientKubernetesError(error)) {
throw error;
}
logger.warn(`Kubernetes request failed while trying to ${description}. Retrying ${attempt}/${attempts}`);
if (delay > 0) {
await sleep(delay * attempt);
}
}
}
}
function withKubernetesRetries(description, client) {
return new Proxy(client, {
get(target, property) {
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') {
return value;
}
return (...args) => withKubernetesRetry(`${description}.${String(property)}`, () => value.apply(target, args));
},
});
}
export function getKubeConfig() {
const kubeconfig = new KubeConfig();
kubeconfig.loadFromDefault();
const appsApi = withKubernetesRetries('AppsV1Api', kubeconfig.makeApiClient(AppsV1Api));
const coreApi = withKubernetesRetries('CoreV1Api', kubeconfig.makeApiClient(CoreV1Api));
return { appsApi, coreApi, kubeconfig };
}
export async function deleteResource(type, namespace, name) {
if (process.env.TEST) {
return;
}
const { appsApi, coreApi } = getKubeConfig();
try {
logger.silly(`Deleting ${type} '${name}' from namespace ${namespace} ... `);
switch (type) {
case 'deployment':
await appsApi.deleteNamespacedDeployment({
name,
namespace,
propagationPolicy: 'Background',
});
break;
case 'service':
await coreApi.deleteNamespacedService({ name, namespace });
break;
default:
await coreApi.deleteNamespacedSecret({ name, namespace });
}
logger.verbose(`Deleted ${type} '${name}' from namespace ${namespace}`);
}
catch (error) {
handleKubernetesError(error);
}
}
/**
* Accepts a url used to call a companion container.
*
* @param url Url for internal communication to a container.
* @returns the metadata used to form the url.
* @example input: `containername-appname-1.companion-containers.svc.cluster.local`
* result: containername-appname-1, 1, appname, appsemble, appsemble-or-namespace
*/
export function parseServiceUrl(url) {
const elements = url.replace('http://', '').split('.');
const deploymentName = elements[0];
const namespace = elements[1];
const appId = deploymentName.split('-').pop();
const appName = deploymentName.replace(`-${appId}`, '');
// @ts-expect-error 2322 null is not assignable to type (strictNullChecks)
return { deploymentName, appId, appName, namespace };
}
export function validateContainerResources(resources) {
// Validate Memory
if (!resources || !resources.limits) {
return { limits: resourceDefaults };
}
const result = resources;
if (resources.limits.memory) {
const unit = result.limits.memory.slice(-2);
const number = result.limits.memory.replace(unit, '');
const parsedValue = Number.parseFloat(number);
const conditionsMemory = [
Number.isNaN(parsedValue),
String(parsedValue) !== number,
parsedValue < 0,
unit !== 'Mi' && unit !== 'Gi',
unit === 'Gi' && parsedValue > maxMemoryGi,
unit === 'Mi' && parsedValue > maxMemoryGi * 1024,
];
if (conditionsMemory.some(Boolean)) {
result.limits.memory = resourceDefaults.memory;
}
}
else {
result.limits.memory = resourceDefaults.memory;
}
if (resources.limits.cpu) {
const parsedValue = Number.parseFloat(resources.limits.cpu);
let cpu = 0;
const pattern = /\b([1-9]|[1-9]\d{1,2})m\b/;
if (pattern.test(resources.limits.cpu)) {
cpu = Number.parseFloat(resources.limits.cpu.slice(0, -1)) / 1000;
}
else if (!Number.isNaN(parsedValue)) {
if (String(parsedValue) === String(resources.limits.cpu)) {
cpu = parsedValue;
}
else {
result.limits.cpu = resourceDefaults.cpu;
}
}
if (cpu > maxCPU || cpu <= 0) {
result.limits.cpu = resourceDefaults.cpu;
}
}
else {
result.limits.cpu = resourceDefaults.cpu;
}
return result;
}
export function isWhitelisted(fullImageName) {
if (process.env.HOST !== 'https://appsemble.app') {
return true;
}
// Allowed registries
// Overwrite the default by setting the
// `WHITELIST_REGISTRIES` env variable
// As space separated list of domains
const whitelistRegistries = process.env.WHITELIST_REGISTRIES
? process.env.WHITELIST_REGISTRIES.split(' ')
: ['registry.hub.docker.com/library/', 'docker.io/library/', 'registry.gitlab.com/appsemble/'];
let whitelisted = false;
const parts = fullImageName.split('/');
// If only image name is provided, it will be pulled from the official docker library
if (parts.length === 1) {
whitelisted = true;
}
else if (parts.length === 2) {
const [namespace] = parts;
// If namespace is 'library', it's an official image
if (namespace === 'library') {
whitelisted = true;
}
}
else if (whitelistRegistries.some((domain) => fullImageName.startsWith(domain))) {
whitelisted = true;
}
if (whitelisted) {
logger.silly(`Image ${fullImageName} found in a whitelisted repository`);
}
else {
logger.error(`Image ${fullImageName} is not in any whitelisted repository`);
}
return whitelisted;
}
export function formatEnv(input, appName, appId) {
const env = [];
if (input && input.length > 0) {
for (const entry of input) {
if (entry.useValueFromSecret) {
env.push({
name: entry.name,
valueFrom: {
secretKeyRef: { key: entry.value, name: formatSecretName(appName, appId) },
},
});
continue;
}
env.push({ name: entry.name, value: entry.value });
}
}
return env;
}
//# sourceMappingURL=helpers.js.map