@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
294 lines • 11.7 kB
JavaScript
import { logger } from '@appsemble/node-utils';
import { appIdLabel, deleteResource, formatEnv, formatSecretName, formatServiceName, getContainerNamespace, getKubeConfig, handleKubernetesError, isWhitelisted, validateContainerResources, } from './helpers.js';
import { generateDeploymentAndServiceSpecs } from './specs.js';
/**
* Creates or updates secret.
* Secret resource is named after the app name + id, where whitespace
* is replaced by '-'.
*
* @param secretName Key of the secret to be created
* @param value The secret value
* @param appName Name of the app
* @param appId Id of the app.
*/
export async function updateNamespacedSecret(secretName, value, appName, appId) {
// If testing, don't create Kubernetes resources
if (process.env.TEST) {
return;
}
const { coreApi } = getKubeConfig();
const namespace = getContainerNamespace();
const formattedName = formatSecretName(appName, appId);
const secretSpec = {
metadata: {
name: formattedName,
namespace,
},
data: { [secretName]: Buffer.from(value).toString('base64') },
};
let existing;
try {
existing = await coreApi.readNamespacedSecret({ name: formattedName, namespace });
}
catch (error) {
handleKubernetesError(error);
}
if (existing) {
logger.silly(`Secret ${formattedName} already exists`);
if (!existing.data) {
existing.data = {};
}
existing.data[secretName] = Buffer.from(value).toString('base64');
try {
const updatedSecret = await coreApi.replaceNamespacedSecret({
name: formattedName,
namespace,
body: existing,
});
logger.info(`Secret ${updatedSecret.metadata?.name} updated successfully`);
}
catch (error) {
handleKubernetesError(error);
}
return;
}
try {
logger.verbose(`Creating secret ${secretSpec.metadata?.name} ...`);
const secret = await coreApi.createNamespacedSecret({ namespace, body: secretSpec });
logger.info(`Secret ${secret.metadata?.name} created successfully`);
}
catch (error) {
handleKubernetesError(error);
}
}
export async function deleteSecret(appName, appId, key) {
if (process.env.TEST) {
return;
}
const { coreApi } = getKubeConfig();
const namespace = getContainerNamespace();
const secretName = formatSecretName(appName, appId);
if (key) {
try {
const secret = await coreApi.readNamespacedSecret({ name: secretName, namespace });
logger.verbose(`Deleteing key ${key} of secret ${secretName} from namespace ${namespace} ...`);
if (!(secret.data && key in secret.data)) {
logger.warn(`Key ${key} does not exist on secret ${secret.metadata?.name}`);
return;
}
delete secret.data[key];
await coreApi.replaceNamespacedSecret({ name: secretName, namespace, body: secret });
logger.verbose(`Deleted key ${key} of secret ${secretName} from namespace ${namespace}.`);
}
catch (error) {
logger.error(`Error deleting ${key ? `key ${key} from` : ''} secret ${secretName}`);
handleKubernetesError(error);
}
}
else {
await deleteResource('secret', namespace, secretName);
}
}
export async function deleteCompanionContainers(serviceName) {
if (process.env.TEST) {
return;
}
const namespace = getContainerNamespace();
await deleteResource('service', namespace, serviceName);
await deleteResource('deployment', namespace, serviceName);
}
export async function updateCompanionContainers(definitions, appName, appId, registry) {
// If testing, don't create Kubernetes resources
if (process.env.NODE_ENV !== 'production') {
return;
}
const { appsApi, coreApi } = getKubeConfig();
const namespace = getContainerNamespace();
let services;
logger.silly(`Listing services in namespace ${namespace} ...`);
try {
services = await coreApi.listNamespacedService({ namespace });
}
catch (error) {
logger.error(`Error listing services in namespace ${namespace}`);
handleKubernetesError(error);
return;
}
// Update or create deployment and service
for (const def of definitions) {
const serviceName = formatServiceName(def.name, appName, appId);
const existing = services?.items?.find((s) => s.metadata?.name === serviceName);
// Update
if (existing) {
logger.verbose(`Updating resources for ${existing.metadata?.name} in namespace ${namespace}`);
const existingServiceName = existing.metadata?.name;
if (!existingServiceName) {
logger.warn(`Service without metadata.name found in namespace ${namespace}, skipping ...`);
continue;
}
const servicePatch = [
{
op: 'replace',
path: '/metadata',
value: { ...def.metadata, name: existingServiceName },
},
{
op: 'replace',
path: '/spec/ports/0/targetPort',
value: def.port ?? 8080,
},
{
op: 'add',
path: '/metadata/labels',
value: { appId },
},
];
// Update service
try {
const updatedService = await coreApi.patchNamespacedService({
name: existingServiceName,
namespace,
body: servicePatch,
});
logger.info(`Service ${updatedService?.metadata?.name} updated`);
}
catch (error) {
logger.error('Error updating service:');
handleKubernetesError(error);
}
// Update deployment
const resources = validateContainerResources(def.resources);
const deploymentPatch = [
{
op: 'replace',
path: '/metadata',
value: { ...def.metadata, name: existingServiceName },
},
{
op: 'replace',
path: '/spec/template/spec/containers/0/ports/0/containerPort',
value: def.port ?? 8080,
},
{
op: 'replace',
path: '/spec/template/spec/containers/0/env',
value: [...formatEnv(def.env ?? [], appName, appId)],
},
{
op: 'add',
path: '/metadata/labels',
value: { appId },
},
{
op: 'replace',
path: '/spec/template/spec/containers/0/resources',
value: resources,
},
];
try {
const updatedDeployment = await appsApi.patchNamespacedDeployment({
name: existingServiceName,
namespace,
body: deploymentPatch,
});
logger.info(`Deployment ${updatedDeployment?.metadata?.name} updated`);
}
catch (error) {
logger.error('Error updating deployment:');
handleKubernetesError(error);
}
// Update pod
try {
const podList = await coreApi.listNamespacedPod({
namespace,
labelSelector: `appId=${appId}`,
});
if (podList.items.length === 0) {
logger.silly(`Deployment ${serviceName} has 0 pods, skipping ...`);
continue;
}
const pod = podList.items.find((item) => item.metadata?.labels?.app === existingServiceName);
if (!pod?.metadata?.name) {
logger.silly(`Deployment ${serviceName} has no matching pod with metadata.name, skipping`);
continue;
}
const podName = pod.metadata.name;
// Set base labels, such as selector, pod name, pod hash
const props = {
...def.metadata,
labels: {
...(def.metadata ? def.metadata.labels : null),
app: pod?.metadata?.labels?.app,
'pod-template-hash': pod?.metadata?.labels?.['pod-template-hash'],
appId,
},
name: podName,
};
const podPatch = [
{
op: 'replace',
path: '/metadata',
value: props,
},
];
const updatedPod = await coreApi.patchNamespacedPod({
name: podName,
namespace,
body: podPatch,
});
logger.verbose(`Pod updated: ${updatedPod?.metadata?.name}`);
}
catch (error) {
handleKubernetesError(error);
}
}
// Create
else {
const { deployment, service } = generateDeploymentAndServiceSpecs(def, serviceName, appName, appId, registry);
// @ts-expect-error 2345 argument of type is not assignable to parameter of type
// (strictNullChecks)
if (isWhitelisted(deployment.spec?.template.spec?.containers[0].image)) {
logger.verbose(`Creating resources in namespace ${namespace} ...`);
try {
const deploymentObj = await appsApi.createNamespacedDeployment({
namespace,
body: deployment,
});
logger.info(`Deployment created: ${deploymentObj.metadata?.name}`);
}
catch (error) {
handleKubernetesError(error);
return;
}
try {
const serviceObj = await coreApi.createNamespacedService({ namespace, body: service });
logger.info(`Service created: ${serviceObj.metadata?.name}`);
}
catch (error) {
handleKubernetesError(error);
}
}
else {
logger.error(`Image ${deployment.spec?.template.spec?.containers[0].image} is not in the repository whitelist!`);
}
}
}
// Delete companions removed from the definition
for (const service of services.items) {
if (service.spec?.type === 'ExternalName') {
continue;
}
if (service.metadata?.labels &&
appIdLabel in service.metadata.labels &&
service.metadata.labels[appIdLabel] === appId) {
const existing = definitions.find((d) => formatServiceName(d.name, appName, appId) === service.metadata?.name);
if (!existing) {
if (!service.metadata?.name) {
continue;
}
await deleteCompanionContainers(service.metadata.name);
}
}
}
}
//# sourceMappingURL=operations.js.map