UNPKG

@appsemble/node-utils

Version:

NodeJS utilities used by Appsemble internally.

222 lines 8.19 kB
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