@appsemble/node-utils 0.29.4 → 0.29.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.29.4/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.29.6/config/assets/logo.svg) Appsemble Node Utilities
2
2
 
3
3
  > NodeJS utilities used by Appsemble internally.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/node-utils)](https://www.npmjs.com/package/@appsemble/node-utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.29.4/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.29.4)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.29.6/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.29.6)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io)
8
8
 
9
9
  ## Table of Contents
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
26
26
 
27
27
  ## License
28
28
 
29
- [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.29.4/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.29.6/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
@@ -0,0 +1,33 @@
1
+ import { type ContainerEnvVar, type ContainerResourceProps, type ContainerResources } from '@appsemble/types';
2
+ import { AppsV1Api, CoreV1Api, KubeConfig, type V1EnvVar } from '@kubernetes/client-node';
3
+ export declare const maxCPU: number;
4
+ export declare const maxMemoryGi: number;
5
+ export declare const appIdLabel = "appId";
6
+ export declare const resourceDefaults: ContainerResourceProps;
7
+ export declare function getKubeConfig(): {
8
+ appsApi: AppsV1Api;
9
+ coreApi: CoreV1Api;
10
+ kubeconfig: KubeConfig;
11
+ };
12
+ export declare function getContainerNamespace(): string;
13
+ export declare function formatServiceName(containerName: string, appName: string, appId: string): string;
14
+ export declare function formatSecretName(appName: string, appId: string): string;
15
+ export declare function handleKubernetesError(error: unknown): void;
16
+ export declare function deleteResource(type: 'deployment' | 'secret' | 'service', namespace: string, name: string): Promise<void>;
17
+ /**
18
+ * Accepts a url used to call a companion container.
19
+ *
20
+ * @param url Url for internal communication to a container.
21
+ * @returns the metadata used to form the url.
22
+ * @example input: `containername-appname-1.companion-containers.svc.cluster.local`
23
+ * result: containername-appname-1, 1, appname, appsemble, appsemble-or-namespace
24
+ */
25
+ export declare function parseServiceUrl(url: string): {
26
+ deploymentName: string;
27
+ appId: string;
28
+ appName: string;
29
+ namespace: string;
30
+ };
31
+ export declare function validateContainerResources(resources: ContainerResources): ContainerResources;
32
+ export declare function isWhitelisted(fullImageName: string): Boolean;
33
+ export declare function formatEnv(input: ContainerEnvVar[], appName: string, appId: string): V1EnvVar[];
@@ -0,0 +1,179 @@
1
+ var _a, _b;
2
+ import { logger } from '@appsemble/node-utils';
3
+ import { AppsV1Api, CoreV1Api, HttpError, KubeConfig, } from '@kubernetes/client-node';
4
+ export const maxCPU = (_a = process.env.MAX_CONTAINER_CPU) !== null && _a !== void 0 ? _a : 3;
5
+ // In gigabytes
6
+ export const maxMemoryGi = (_b = process.env.MAX_CONTAINER_MEMORY) !== null && _b !== void 0 ? _b : 3;
7
+ export const appIdLabel = 'appId';
8
+ export const resourceDefaults = { memory: '128Mi', cpu: '0.1' };
9
+ export function getKubeConfig() {
10
+ const kubeconfig = new KubeConfig();
11
+ kubeconfig.loadFromDefault();
12
+ const appsApi = kubeconfig.makeApiClient(AppsV1Api);
13
+ const coreApi = kubeconfig.makeApiClient(CoreV1Api);
14
+ return { appsApi, coreApi, kubeconfig };
15
+ }
16
+ export function getContainerNamespace() {
17
+ var _a;
18
+ return `companion-containers-${(_a = process.env.SERVICE_NAME) !== null && _a !== void 0 ? _a : 'appsemble'}`;
19
+ }
20
+ export function formatServiceName(containerName, appName, appId) {
21
+ const serviceName = `${containerName}-${appName}-${appId}`.replaceAll(' ', '-').toLowerCase();
22
+ return serviceName;
23
+ }
24
+ export function formatSecretName(appName, appId) {
25
+ return `${appName}-${appId}`.toLocaleLowerCase().replaceAll(' ', '-');
26
+ }
27
+ export function handleKubernetesError(error) {
28
+ if (error instanceof HttpError) {
29
+ const { statusCode } = error;
30
+ if (statusCode) {
31
+ logger.warn(`Kubernetes error with status code ${statusCode}:`);
32
+ }
33
+ logger.warn(error.body);
34
+ return;
35
+ }
36
+ logger.error(error);
37
+ }
38
+ export async function deleteResource(type, namespace, name) {
39
+ if (process.env.TEST) {
40
+ return;
41
+ }
42
+ const { appsApi, coreApi } = getKubeConfig();
43
+ try {
44
+ logger.silly(`Deleting ${type} '${name}' from namespace ${namespace} ... `);
45
+ switch (type) {
46
+ case 'deployment':
47
+ await appsApi.deleteNamespacedDeployment(name, namespace, undefined, undefined, undefined, undefined, 'Background');
48
+ break;
49
+ case 'service':
50
+ await coreApi.deleteNamespacedService(name, namespace);
51
+ break;
52
+ default:
53
+ await coreApi.deleteNamespacedSecret(name, namespace);
54
+ }
55
+ logger.verbose(`Deleted ${type} '${name}' from namespace ${namespace}`);
56
+ }
57
+ catch (error) {
58
+ handleKubernetesError(error);
59
+ }
60
+ }
61
+ /**
62
+ * Accepts a url used to call a companion container.
63
+ *
64
+ * @param url Url for internal communication to a container.
65
+ * @returns the metadata used to form the url.
66
+ * @example input: `containername-appname-1.companion-containers.svc.cluster.local`
67
+ * result: containername-appname-1, 1, appname, appsemble, appsemble-or-namespace
68
+ */
69
+ export function parseServiceUrl(url) {
70
+ const elements = url.replace('http://', '').split('.');
71
+ const deploymentName = elements[0];
72
+ const namespace = elements[1];
73
+ const appId = deploymentName.split('-').pop();
74
+ const appName = deploymentName.replace(`-${appId}`, '');
75
+ return { deploymentName, appId, appName, namespace };
76
+ }
77
+ export function validateContainerResources(resources) {
78
+ // Validate Memory
79
+ if (!resources || !resources.limits) {
80
+ return { limits: resourceDefaults };
81
+ }
82
+ const result = resources;
83
+ if (resources.limits.memory) {
84
+ const unit = result.limits.memory.slice(-2);
85
+ const number = result.limits.memory.replace(unit, '');
86
+ const parsedValue = Number.parseFloat(number);
87
+ const conditionsMemory = [
88
+ Number.isNaN(parsedValue),
89
+ String(parsedValue) !== number,
90
+ parsedValue < 0,
91
+ unit !== 'Mi' && unit !== 'Gi',
92
+ unit === 'Gi' && parsedValue > maxMemoryGi,
93
+ unit === 'Mi' && parsedValue > maxMemoryGi * 1024,
94
+ ];
95
+ if (conditionsMemory.some(Boolean)) {
96
+ result.limits.memory = resourceDefaults.memory;
97
+ }
98
+ }
99
+ else {
100
+ result.limits.memory = resourceDefaults.memory;
101
+ }
102
+ if (resources.limits.cpu) {
103
+ const parsedValue = Number.parseFloat(resources.limits.cpu);
104
+ let cpu = 0;
105
+ const pattern = /\b([1-9]|[1-9]\d{1,2})m\b/;
106
+ if (pattern.test(resources.limits.cpu)) {
107
+ cpu = Number.parseFloat(resources.limits.cpu.slice(0, -1)) / 1000;
108
+ }
109
+ else if (!Number.isNaN(parsedValue)) {
110
+ if (String(parsedValue) === String(resources.limits.cpu)) {
111
+ cpu = parsedValue;
112
+ }
113
+ else {
114
+ result.limits.cpu = resourceDefaults.cpu;
115
+ }
116
+ }
117
+ if (cpu > maxCPU || cpu <= 0) {
118
+ result.limits.cpu = resourceDefaults.cpu;
119
+ }
120
+ }
121
+ else {
122
+ result.limits.cpu = resourceDefaults.cpu;
123
+ }
124
+ return result;
125
+ }
126
+ export function isWhitelisted(fullImageName) {
127
+ if (process.env.HOST !== 'https://appsemble.app') {
128
+ return true;
129
+ }
130
+ // Allowed registries
131
+ // Overwrite the default by setting the
132
+ // `WHITELIST_REGISTRIES` env variable
133
+ // As space separated list of domains
134
+ const whitelistRegistries = process.env.WHITELIST_REGISTRIES
135
+ ? process.env.WHITELIST_REGISTRIES.split(' ')
136
+ : ['registry.hub.docker.com/library/', 'docker.io/library/', 'registry.gitlab.com/appsemble/'];
137
+ let whitelisted = false;
138
+ const parts = fullImageName.split('/');
139
+ // If only image name is provided, it will be pulled from the official docker library
140
+ if (parts.length === 1) {
141
+ whitelisted = true;
142
+ }
143
+ else if (parts.length === 2) {
144
+ const [namespace] = parts;
145
+ // If namespace is 'library', it's an official image
146
+ if (namespace === 'library') {
147
+ whitelisted = true;
148
+ }
149
+ }
150
+ else if (whitelistRegistries.some((domain) => fullImageName.startsWith(domain))) {
151
+ whitelisted = true;
152
+ }
153
+ if (whitelisted) {
154
+ logger.silly(`Image ${fullImageName} found in a whitelisted repository`);
155
+ }
156
+ else {
157
+ logger.error(`Image ${fullImageName} is not in any whitelisted repository`);
158
+ }
159
+ return whitelisted;
160
+ }
161
+ export function formatEnv(input, appName, appId) {
162
+ const env = [];
163
+ if (input && input.length > 0) {
164
+ for (const entry of input) {
165
+ if (entry.useValueFromSecret) {
166
+ env.push({
167
+ name: entry.name,
168
+ valueFrom: {
169
+ secretKeyRef: { key: entry.value, name: formatSecretName(appName, appId) },
170
+ },
171
+ });
172
+ continue;
173
+ }
174
+ env.push({ name: entry.name, value: entry.value });
175
+ }
176
+ }
177
+ return env;
178
+ }
179
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1,5 @@
1
+ export * from './helpers.js';
2
+ export * from './operations.js';
3
+ export * from './scale.js';
4
+ export * from './specs.js';
5
+ export * from './logs.js';
@@ -0,0 +1,6 @@
1
+ export * from './helpers.js';
2
+ export * from './operations.js';
3
+ export * from './scale.js';
4
+ export * from './specs.js';
5
+ export * from './logs.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,2 @@
1
+ import { type LogObject } from '@appsemble/types';
2
+ export declare function getLogs(deploymentName: string, fromAppsemble?: boolean): Promise<LogObject[]>;
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import stream from 'node:stream';
3
+ import { Log } from '@kubernetes/client-node';
4
+ import { getContainerNamespace, getKubeConfig, handleKubernetesError } from './helpers.js';
5
+ import { logger } from '../logger.js';
6
+ function fetchLogs(namespace, podName, containerName) {
7
+ const { kubeconfig } = getKubeConfig();
8
+ const log = new Log(kubeconfig);
9
+ const logStream = new stream.PassThrough();
10
+ return new Promise((resolve, reject) => {
11
+ log
12
+ .log(namespace, podName, containerName || '', logStream)
13
+ .then(() => {
14
+ let logs = '';
15
+ logStream.on('data', (chunk) => {
16
+ logs += String(chunk);
17
+ });
18
+ logStream.on('end', () => {
19
+ resolve(logs);
20
+ });
21
+ logStream.on('error', (err) => {
22
+ reject(err);
23
+ });
24
+ })
25
+ .catch((err) => {
26
+ reject(err);
27
+ });
28
+ });
29
+ }
30
+ function filterLogEntries(entries, deploymentName, fromAppsemble) {
31
+ let res = entries;
32
+ if (fromAppsemble) {
33
+ res = entries.filter((e) => e.includes(deploymentName));
34
+ }
35
+ return res;
36
+ }
37
+ export async function getLogs(deploymentName, fromAppsemble) {
38
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
39
+ const { coreApi } = getKubeConfig();
40
+ let pods;
41
+ const containerNamespace = getContainerNamespace();
42
+ let appsembleNamespace = 'appsemble';
43
+ const appsembleServiceName = (_a = process.env.SERVICE_NAME) !== null && _a !== void 0 ? _a : 'appsemble';
44
+ logger.silly(`Using service name ${appsembleServiceName}`);
45
+ try {
46
+ const NAMESPACE_FILE_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
47
+ const namespace = await readFile(NAMESPACE_FILE_PATH, 'utf8');
48
+ appsembleNamespace = namespace.trim();
49
+ }
50
+ catch (error) {
51
+ logger.error('Error fetching namespace name. Using `appsemble` as default');
52
+ logger.error(error);
53
+ }
54
+ logger.silly(`Using namespace ${appsembleNamespace}`);
55
+ try {
56
+ pods = (await coreApi.listNamespacedPod(fromAppsemble ? appsembleNamespace : containerNamespace)).body;
57
+ }
58
+ catch (error) {
59
+ handleKubernetesError(error);
60
+ return [];
61
+ }
62
+ if (pods.items.length === 0) {
63
+ logger.warn(`No pods found for deployment ${deploymentName} in namespace ${containerNamespace}`);
64
+ return [];
65
+ }
66
+ const podLogs = [];
67
+ for (const pod of pods.items) {
68
+ if (((_c = (_b = pod.metadata) === null || _b === void 0 ? void 0 : _b.ownerReferences) === null || _c === void 0 ? void 0 : _c.find((r) => r.kind === 'ReplicaSet')) &&
69
+ ((_d = pod.status) === null || _d === void 0 ? void 0 : _d.phase) === 'Running') {
70
+ if (fromAppsemble && !((_e = pod.metadata) === null || _e === void 0 ? void 0 : _e.name.includes(appsembleServiceName))) {
71
+ continue;
72
+ }
73
+ if (!fromAppsemble && !((_f = pod.metadata) === null || _f === void 0 ? void 0 : _f.name.includes(deploymentName))) {
74
+ continue;
75
+ }
76
+ logger.silly(`Pod: ${(_g = pod.metadata) === null || _g === void 0 ? void 0 : _g.name}, status: ${pod.status}, deployment: ${deploymentName}`);
77
+ try {
78
+ const res = await fetchLogs(fromAppsemble ? appsembleNamespace : containerNamespace, (_h = pod.metadata) === null || _h === void 0 ? void 0 : _h.name, fromAppsemble ? 'appsemble' : deploymentName);
79
+ let entries = res.split('\n');
80
+ logger.silly(`Number of entries found for pod ${(_j = pod.metadata) === null || _j === void 0 ? void 0 : _j.name}: ${entries.length}`);
81
+ entries = filterLogEntries([...entries], deploymentName, fromAppsemble);
82
+ podLogs.push({ fromAppsemble, entries });
83
+ }
84
+ catch (error) {
85
+ handleKubernetesError(error);
86
+ }
87
+ }
88
+ }
89
+ return podLogs;
90
+ }
91
+ //# sourceMappingURL=logs.js.map
@@ -0,0 +1,15 @@
1
+ import { type CompanionContainerDefinition } from '@appsemble/types';
2
+ /**
3
+ * Creates or updates secret.
4
+ * Secret resource is named after the app name + id, where whitespace
5
+ * is replaced by '-'.
6
+ *
7
+ * @param secretName Key of the secret to be created
8
+ * @param value The secret value
9
+ * @param appName Name of the app
10
+ * @param appId Id of the app.
11
+ */
12
+ export declare function updateNamespacedSecret(secretName: string, value: string, appName: string, appId: string): Promise<void>;
13
+ export declare function deleteSecret(appName: string, appId: string, key?: string): Promise<void>;
14
+ export declare function deleteCompanionContainers(serviceName: string): Promise<void>;
15
+ export declare function updateCompanionContainers(definitions: CompanionContainerDefinition[], appName: string, appId: string, registry?: string): Promise<void>;
@@ -0,0 +1,271 @@
1
+ import { logger } from '@appsemble/node-utils';
2
+ import { appIdLabel, deleteResource, formatEnv, formatSecretName, formatServiceName, getContainerNamespace, getKubeConfig, handleKubernetesError, isWhitelisted, validateContainerResources, } from './helpers.js';
3
+ import { generateDeploymentAndServiceSpecs } from './specs.js';
4
+ /**
5
+ * Creates or updates secret.
6
+ * Secret resource is named after the app name + id, where whitespace
7
+ * is replaced by '-'.
8
+ *
9
+ * @param secretName Key of the secret to be created
10
+ * @param value The secret value
11
+ * @param appName Name of the app
12
+ * @param appId Id of the app.
13
+ */
14
+ export async function updateNamespacedSecret(secretName, value, appName, appId) {
15
+ var _a, _b;
16
+ // If testing, don't create Kubernetes resources
17
+ if (process.env.TEST) {
18
+ return;
19
+ }
20
+ const { coreApi } = getKubeConfig();
21
+ const namespace = getContainerNamespace();
22
+ const formattedName = formatSecretName(appName, appId);
23
+ const secretSpec = {
24
+ metadata: {
25
+ name: formattedName,
26
+ namespace,
27
+ },
28
+ data: { [secretName]: Buffer.from(value).toString('base64') },
29
+ };
30
+ let existing;
31
+ try {
32
+ existing = (await coreApi.readNamespacedSecret(formattedName, namespace)).body;
33
+ }
34
+ catch (error) {
35
+ handleKubernetesError(error);
36
+ }
37
+ if (existing) {
38
+ logger.silly(`Secret ${formattedName} already exists`);
39
+ if (!existing.data) {
40
+ existing.data = {};
41
+ }
42
+ existing.data[secretName] = Buffer.from(value).toString('base64');
43
+ try {
44
+ const { body: updatedSecret } = await coreApi.replaceNamespacedSecret(formattedName, namespace, existing);
45
+ logger.info(`Secret ${updatedSecret.metadata.name} updated successfully`);
46
+ }
47
+ catch (error) {
48
+ handleKubernetesError(error);
49
+ }
50
+ return;
51
+ }
52
+ try {
53
+ logger.verbose(`Creating secret ${(_a = secretSpec.metadata) === null || _a === void 0 ? void 0 : _a.name} ...`);
54
+ const { body: secret } = await coreApi.createNamespacedSecret(namespace, secretSpec);
55
+ logger.info(`Secret ${(_b = secret.metadata) === null || _b === void 0 ? void 0 : _b.name} created successfully`);
56
+ }
57
+ catch (error) {
58
+ handleKubernetesError(error);
59
+ }
60
+ }
61
+ export async function deleteSecret(appName, appId, key) {
62
+ var _a;
63
+ if (process.env.TEST) {
64
+ return;
65
+ }
66
+ const { coreApi } = getKubeConfig();
67
+ const namespace = getContainerNamespace();
68
+ const secretName = formatSecretName(appName, appId);
69
+ if (key) {
70
+ try {
71
+ const { body: secret } = await coreApi.readNamespacedSecret(secretName, namespace);
72
+ logger.verbose(`Deleteing key ${key} of secret ${secretName} from namespace ${namespace} ...`);
73
+ if (!(key in secret.data)) {
74
+ logger.warn(`Key ${key} does not exist on secret ${(_a = secret.metadata) === null || _a === void 0 ? void 0 : _a.name}`);
75
+ return;
76
+ }
77
+ delete secret.data[key];
78
+ await coreApi.replaceNamespacedSecret(secretName, namespace, secret);
79
+ logger.verbose(`Deleted key ${key} of secret ${secretName} from namespace ${namespace}.`);
80
+ }
81
+ catch (error) {
82
+ logger.error(`Error deleting ${key ? `key ${key} from` : ''} secret ${secretName}`);
83
+ handleKubernetesError(error);
84
+ }
85
+ }
86
+ else {
87
+ await deleteResource('secret', namespace, secretName);
88
+ }
89
+ }
90
+ export async function deleteCompanionContainers(serviceName) {
91
+ if (process.env.TEST) {
92
+ return;
93
+ }
94
+ const namespace = getContainerNamespace();
95
+ await deleteResource('service', namespace, serviceName);
96
+ await deleteResource('deployment', namespace, serviceName);
97
+ }
98
+ export async function updateCompanionContainers(definitions, appName, appId, registry) {
99
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
100
+ // If testing, don't create Kubernetes resources
101
+ if (process.env.TEST) {
102
+ return;
103
+ }
104
+ const { appsApi, coreApi } = getKubeConfig();
105
+ const namespace = getContainerNamespace();
106
+ let services;
107
+ logger.silly(`Listing services in namespace ${namespace} ...`);
108
+ try {
109
+ services = (await coreApi.listNamespacedService(namespace)).body;
110
+ }
111
+ catch (error) {
112
+ logger.error(`Error listing services in namespace ${namespace}`);
113
+ handleKubernetesError(error);
114
+ return;
115
+ }
116
+ // Update or create deployment and service
117
+ for (const def of definitions) {
118
+ const serviceName = formatServiceName(def.name, appName, appId);
119
+ const existing = (_a = services === null || services === void 0 ? void 0 : services.items) === null || _a === void 0 ? void 0 : _a.find((s) => s.metadata.name === serviceName);
120
+ // Update
121
+ if (existing) {
122
+ logger.verbose(`Updating resources for ${existing.metadata.name} in namespace ${namespace}`);
123
+ const patchOptions = [
124
+ (_b = existing.metadata) === null || _b === void 0 ? void 0 : _b.name,
125
+ namespace,
126
+ [
127
+ {
128
+ op: 'replace',
129
+ path: '/metadata',
130
+ value: { ...def.metadata, name: existing.metadata.name },
131
+ },
132
+ {
133
+ op: 'replace',
134
+ path: '/spec/ports/0/targetPort',
135
+ value: (_c = def.port) !== null && _c !== void 0 ? _c : 8080,
136
+ },
137
+ {
138
+ op: 'add',
139
+ path: '/metadata/labels',
140
+ value: { appId },
141
+ },
142
+ ],
143
+ undefined,
144
+ undefined,
145
+ undefined,
146
+ undefined,
147
+ undefined,
148
+ { headers: { 'Content-Type': 'application/json-patch+json' } },
149
+ ];
150
+ // Update service
151
+ try {
152
+ const updatedService = await coreApi.patchNamespacedService(...patchOptions);
153
+ logger.info(`Service ${(_e = (_d = updatedService === null || updatedService === void 0 ? void 0 : updatedService.body) === null || _d === void 0 ? void 0 : _d.metadata) === null || _e === void 0 ? void 0 : _e.name} updated`);
154
+ }
155
+ catch (error) {
156
+ logger.error('Error updating service:');
157
+ handleKubernetesError(error);
158
+ }
159
+ // Update deployment
160
+ const resources = validateContainerResources(def.resources);
161
+ patchOptions[2] = [
162
+ {
163
+ op: 'replace',
164
+ path: '/metadata',
165
+ value: { ...def.metadata, name: existing.metadata.name },
166
+ },
167
+ {
168
+ op: 'replace',
169
+ path: '/spec/template/spec/containers/0/ports/0/containerPort',
170
+ value: (_f = def.port) !== null && _f !== void 0 ? _f : 8080,
171
+ },
172
+ {
173
+ op: 'replace',
174
+ path: '/spec/template/spec/containers/0/env',
175
+ value: [...formatEnv(def.env, appName, appId)],
176
+ },
177
+ {
178
+ op: 'add',
179
+ path: '/metadata/labels',
180
+ value: { appId },
181
+ },
182
+ {
183
+ op: 'replace',
184
+ path: '/spec/template/spec/containers/0/resources',
185
+ value: resources,
186
+ },
187
+ ];
188
+ try {
189
+ const updatedDeployment = await appsApi.patchNamespacedDeployment(...patchOptions);
190
+ logger.info(`Deployment ${(_h = (_g = updatedDeployment === null || updatedDeployment === void 0 ? void 0 : updatedDeployment.body) === null || _g === void 0 ? void 0 : _g.metadata) === null || _h === void 0 ? void 0 : _h.name} updated`);
191
+ }
192
+ catch (error) {
193
+ logger.error('Error updating deployment:');
194
+ handleKubernetesError(error);
195
+ }
196
+ // Update pod
197
+ try {
198
+ const { body } = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `appId=${appId}`);
199
+ if (((_j = body === null || body === void 0 ? void 0 : body.items) === null || _j === void 0 ? void 0 : _j.length) === 0) {
200
+ logger.silly(`Deployment ${serviceName} has 0 pods, skipping ...`);
201
+ continue;
202
+ }
203
+ const pod = (_k = body === null || body === void 0 ? void 0 : body.items) === null || _k === void 0 ? void 0 : _k.find((i) => i.metadata.labels.app === existing.metadata.name);
204
+ // Set base labels, such as selector, pod name, pod hash
205
+ const props = {
206
+ ...def.metadata,
207
+ labels: {
208
+ ...(def.metadata ? def.metadata.labels : null),
209
+ app: pod.metadata.labels.app,
210
+ 'pod-template-hash': pod.metadata.labels['pod-template-hash'],
211
+ appId,
212
+ },
213
+ name: pod.metadata.name,
214
+ };
215
+ patchOptions[0] = (_l = pod === null || pod === void 0 ? void 0 : pod.metadata) === null || _l === void 0 ? void 0 : _l.name;
216
+ patchOptions[2] = [
217
+ {
218
+ op: 'replace',
219
+ path: '/metadata',
220
+ value: props,
221
+ },
222
+ ];
223
+ const updatedPod = await coreApi.patchNamespacedPod(...patchOptions);
224
+ logger.verbose(`Pod updated: ${(_o = (_m = updatedPod === null || updatedPod === void 0 ? void 0 : updatedPod.body) === null || _m === void 0 ? void 0 : _m.metadata) === null || _o === void 0 ? void 0 : _o.name}`);
225
+ }
226
+ catch (error) {
227
+ handleKubernetesError(error);
228
+ }
229
+ }
230
+ // Create
231
+ else {
232
+ const { deployment, service } = generateDeploymentAndServiceSpecs(def, serviceName, appName, appId, registry);
233
+ if (isWhitelisted(deployment.spec.template.spec.containers[0].image)) {
234
+ logger.verbose(`Creating resources in namespace ${namespace} ...`);
235
+ try {
236
+ const deploymentObj = await appsApi.createNamespacedDeployment(namespace, deployment);
237
+ logger.info(`Deployment created: ${(_q = (_p = deploymentObj.body) === null || _p === void 0 ? void 0 : _p.metadata) === null || _q === void 0 ? void 0 : _q.name}`);
238
+ }
239
+ catch (error) {
240
+ handleKubernetesError(error);
241
+ return;
242
+ }
243
+ try {
244
+ const serviceObj = await coreApi.createNamespacedService(namespace, service);
245
+ logger.info(`Service created: ${(_r = serviceObj.body.metadata) === null || _r === void 0 ? void 0 : _r.name}`);
246
+ }
247
+ catch (error) {
248
+ handleKubernetesError(error);
249
+ }
250
+ }
251
+ else {
252
+ logger.error(`Image ${deployment.spec.template.spec.containers[0].image} is not in the repository whitelist!`);
253
+ }
254
+ }
255
+ }
256
+ // Delete companions removed from the definition
257
+ for (const service of services.items) {
258
+ if (service.spec.type === 'ExternalName') {
259
+ continue;
260
+ }
261
+ if (((_s = service.metadata) === null || _s === void 0 ? void 0 : _s.labels) &&
262
+ appIdLabel in service.metadata.labels &&
263
+ service.metadata.labels[appIdLabel] === appId) {
264
+ const existing = definitions.find((d) => formatServiceName(d.name, appName, appId) === service.metadata.name);
265
+ if (!existing) {
266
+ await deleteCompanionContainers((_t = service.metadata) === null || _t === void 0 ? void 0 : _t.name);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ //# sourceMappingURL=operations.js.map
@@ -0,0 +1,4 @@
1
+ export declare function scaleDeployment(namespace: string, deploymentName: string, replicas?: number): Promise<void>;
2
+ export declare function stopIdleContainers(interval?: number): Promise<void>;
3
+ export declare function waitForPodReadiness(namespace: string, appSelector: string, maxWait?: number): Promise<void>;
4
+ export declare function setLastRequestAnnotation(namespace: string, deploymentName: string): Promise<void>;
@@ -0,0 +1,117 @@
1
+ import { logger } from '@appsemble/node-utils';
2
+ import { getContainerNamespace, getKubeConfig, handleKubernetesError } from './helpers.js';
3
+ export async function scaleDeployment(namespace, deploymentName, replicas = 0) {
4
+ const { appsApi } = getKubeConfig();
5
+ const patch = [];
6
+ patch.push({
7
+ op: 'replace',
8
+ path: '/spec/replicas',
9
+ value: replicas,
10
+ });
11
+ try {
12
+ logger.verbose(`Scaling deployment ${deploymentName} to ${replicas} replicas ... `);
13
+ await appsApi.patchNamespacedDeployment(deploymentName, namespace, patch, undefined, undefined, undefined, undefined, undefined, { headers: { 'Content-Type': 'application/json-patch+json' } });
14
+ logger.verbose(`Deployment ${deploymentName} scaled to ${replicas} replicas successfully `);
15
+ }
16
+ catch (error) {
17
+ handleKubernetesError(error);
18
+ throw error;
19
+ }
20
+ }
21
+ export async function stopIdleContainers(interval = 10) {
22
+ var _a;
23
+ const { appsApi } = getKubeConfig();
24
+ let deployments;
25
+ logger.verbose('Scaling containers');
26
+ try {
27
+ deployments = (await appsApi.listDeploymentForAllNamespaces()).body;
28
+ }
29
+ catch (error) {
30
+ handleKubernetesError(error);
31
+ return;
32
+ }
33
+ for (const deployment of deployments.items) {
34
+ const { annotations, name, namespace } = deployment.metadata;
35
+ if (((_a = deployment.metadata) === null || _a === void 0 ? void 0 : _a.namespace) !== getContainerNamespace() ||
36
+ deployment.spec.replicas === 0) {
37
+ continue;
38
+ }
39
+ const milliseconds = interval * 60 * 1000;
40
+ const now = new Date();
41
+ const lastCall = annotations.lastRequestTimestamp
42
+ ? new Date(annotations.lastRequestTimestamp)
43
+ : null;
44
+ // Set timestamp metadata if missing, and continue
45
+ if (!lastCall) {
46
+ const patch = [
47
+ {
48
+ op: 'add',
49
+ path: '/metadata/annotations',
50
+ value: { lastRequestTimestamp: now.toISOString() },
51
+ },
52
+ ];
53
+ try {
54
+ logger.silly(`Updating metadata of deployment ${name}`);
55
+ await appsApi.patchNamespacedDeployment(name, namespace, patch, undefined, undefined, undefined, undefined, undefined, { headers: { 'Content-Type': 'application/json-patch+json' } });
56
+ logger.verbose(`Updated metadata of deployment ${name} successfully `);
57
+ }
58
+ catch (error) {
59
+ handleKubernetesError(error);
60
+ }
61
+ continue;
62
+ }
63
+ // If timestamp exists and is old enough, scale down
64
+ const diff = now.getTime() - lastCall.getTime();
65
+ if (Math.abs(diff) > milliseconds) {
66
+ await scaleDeployment(namespace, name, 0);
67
+ }
68
+ }
69
+ }
70
+ export async function waitForPodReadiness(namespace, appSelector, maxWait = 10000) {
71
+ var _a;
72
+ let isReady;
73
+ const interval = 500;
74
+ let elapsed = 0;
75
+ const { coreApi } = getKubeConfig();
76
+ while (!isReady) {
77
+ const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `app=${appSelector}`);
78
+ if (pods.body.items.length > 0) {
79
+ isReady = (_a = pods.body) === null || _a === void 0 ? void 0 : _a.items.every((pod) => {
80
+ var _a, _b;
81
+ return (_b = (_a = pod.status) === null || _a === void 0 ? void 0 : _a.conditions) === null || _b === void 0 ? void 0 : _b.some((condition) => condition.type === 'Ready' && condition.status === 'True');
82
+ });
83
+ }
84
+ if (!isReady) {
85
+ logger.silly(`Waiting for ${appSelector} to be ready ...`);
86
+ await new Promise((resolve) => {
87
+ setTimeout(resolve, interval);
88
+ });
89
+ elapsed += interval;
90
+ if (elapsed >= maxWait) {
91
+ logger.warn(`Deployment ${appSelector} does not respond`);
92
+ return;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ export async function setLastRequestAnnotation(namespace, deploymentName) {
98
+ const { appsApi } = getKubeConfig();
99
+ const now = new Date();
100
+ const patch = [
101
+ {
102
+ op: 'add',
103
+ path: '/metadata/annotations',
104
+ value: { lastRequestTimestamp: now.toISOString() },
105
+ },
106
+ ];
107
+ try {
108
+ logger.silly(`Setting 'lastRequestTimestamp' annotation of '${deploymentName}' to ${now.toISOString()}`);
109
+ await appsApi.patchNamespacedDeployment(deploymentName, namespace, patch, undefined, undefined, undefined, undefined, undefined, { headers: { 'Content-Type': 'application/json-patch+json' } });
110
+ logger.silly(`'LastRequestTimestamp' annotation of '${deploymentName}' updated successfully`);
111
+ }
112
+ catch (error) {
113
+ logger.warn('Could not set last request annotation:');
114
+ handleKubernetesError(error);
115
+ }
116
+ }
117
+ //# sourceMappingURL=scale.js.map
@@ -0,0 +1,17 @@
1
+ import { type CompanionContainerDefinition } from '@appsemble/types';
2
+ import { type V1Deployment, type V1Service } from '@kubernetes/client-node';
3
+ /**
4
+ * Create the spec objects for a service, deployment, and pod.
5
+ * Use for a single companion container.
6
+ *
7
+ * @param definition Companion container properties.
8
+ * @param name Name to be used for the service and deployment objects.
9
+ * @param appName Name of the app.
10
+ * @param appId Id of the app creating the containers.
11
+ * @param registry The default container registry to be used.
12
+ * @returns `V1Service` and `V1Deployment` spec objects.
13
+ */
14
+ export declare function generateDeploymentAndServiceSpecs(definition: CompanionContainerDefinition, name: string, appName: string, appId: string, registry?: string): {
15
+ service: V1Service;
16
+ deployment: V1Deployment;
17
+ };
@@ -0,0 +1,77 @@
1
+ import { formatEnv, logger, resourceDefaults, validateContainerResources, } from '@appsemble/node-utils';
2
+ /**
3
+ * Create the spec objects for a service, deployment, and pod.
4
+ * Use for a single companion container.
5
+ *
6
+ * @param definition Companion container properties.
7
+ * @param name Name to be used for the service and deployment objects.
8
+ * @param appName Name of the app.
9
+ * @param appId Id of the app creating the containers.
10
+ * @param registry The default container registry to be used.
11
+ * @returns `V1Service` and `V1Deployment` spec objects.
12
+ */
13
+ export function generateDeploymentAndServiceSpecs(definition, name, appName, appId, registry) {
14
+ var _a, _b, _c, _d, _e, _f, _g, _h;
15
+ let { image, resources } = definition;
16
+ logger.silly(`Using image ${image} ${registry ? `from registry ${registry}` : ''}`);
17
+ if (registry && !image.includes('/')) {
18
+ // If registry ends with a '/'
19
+ image = `${registry.endsWith('/') ? registry.slice(0, -1) : registry}/${image}`;
20
+ }
21
+ const validatedResources = validateContainerResources(resources);
22
+ const env = formatEnv(definition.env, appName, appId);
23
+ // Env should have string keys!
24
+ const podTemplateSpec = {
25
+ metadata: {
26
+ ...definition.metadata,
27
+ labels: { app: name, ...(_a = definition.metadata) === null || _a === void 0 ? void 0 : _a.labels, appId },
28
+ },
29
+ spec: {
30
+ containers: [
31
+ {
32
+ name,
33
+ image,
34
+ ports: [{ containerPort: (_b = definition.port) !== null && _b !== void 0 ? _b : 8080 }],
35
+ env,
36
+ resources: {
37
+ limits: { ...validatedResources.limits },
38
+ requests: { ...resourceDefaults },
39
+ },
40
+ },
41
+ ],
42
+ },
43
+ };
44
+ // Define the Deployment specification
45
+ const deploymentSpec = {
46
+ replicas: 1,
47
+ selector: {
48
+ matchLabels: { app: name, appId },
49
+ ...(_c = definition.metadata) === null || _c === void 0 ? void 0 : _c.selector,
50
+ },
51
+ template: podTemplateSpec,
52
+ };
53
+ // Define the Deployment
54
+ const deployment = {
55
+ metadata: {
56
+ name,
57
+ ...definition.metadata,
58
+ labels: { ...(_d = definition.metadata) === null || _d === void 0 ? void 0 : _d.labels, appId },
59
+ },
60
+ spec: deploymentSpec,
61
+ };
62
+ // Default service type is ClusterIP
63
+ const serviceSpec = {
64
+ selector: { app: name, ...(_f = (_e = definition.metadata) === null || _e === void 0 ? void 0 : _e.selector) === null || _f === void 0 ? void 0 : _f.matchLabels, appId },
65
+ ports: [{ port: 80, targetPort: (_g = definition.port) !== null && _g !== void 0 ? _g : 8080 }],
66
+ };
67
+ const service = {
68
+ metadata: {
69
+ name,
70
+ ...definition.metadata,
71
+ labels: { ...(_h = definition.metadata) === null || _h === void 0 ? void 0 : _h.labels, appId },
72
+ },
73
+ spec: serviceSpec,
74
+ };
75
+ return { service, deployment };
76
+ }
77
+ //# sourceMappingURL=specs.js.map
package/index.d.ts CHANGED
@@ -35,3 +35,4 @@ export * from './EmailQuotaExceededError.js';
35
35
  export * from './EmailError.js';
36
36
  export * from './UserPropertiesError.js';
37
37
  export * from './authentication.js';
38
+ export * from './container/index.js';
package/index.js CHANGED
@@ -35,4 +35,5 @@ export * from './EmailQuotaExceededError.js';
35
35
  export * from './EmailError.js';
36
36
  export * from './UserPropertiesError.js';
37
37
  export * from './authentication.js';
38
+ export * from './container/index.js';
38
39
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.29.4",
3
+ "version": "0.29.6",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -38,10 +38,11 @@
38
38
  "test": "vitest"
39
39
  },
40
40
  "dependencies": {
41
- "@appsemble/types": "0.29.4",
42
- "@appsemble/utils": "0.29.4",
41
+ "@appsemble/types": "0.29.6",
42
+ "@appsemble/utils": "0.29.6",
43
43
  "@formatjs/fast-memoize": "^2.0.0",
44
44
  "@fortawesome/fontawesome-free": "^6.0.0",
45
+ "@kubernetes/client-node": "0.21.0",
45
46
  "@koa/cors": "^4.0.0",
46
47
  "@odata/parser": "^0.2.0",
47
48
  "@types/koa": "^2.0.0",
@@ -1,7 +1,7 @@
1
- import { assertKoaError, getRemapperContext, logger, throwKoaError, version, } from '@appsemble/node-utils';
1
+ import { assertKoaError, createFormData, getContainerNamespace, getRemapperContext, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils';
2
2
  import { defaultLocale, remap } from '@appsemble/utils';
3
3
  import axios from 'axios';
4
- import { get, pick } from 'lodash-es';
4
+ import { get, mapValues, pick } from 'lodash-es';
5
5
  import { EmailQuotaExceededError } from '../../EmailQuotaExceededError.js';
6
6
  /**
7
7
  * These response headers are forwarded when proxying requests.
@@ -49,12 +49,38 @@ async function handleNotify(ctx, app, action, options) {
49
49
  await sendNotifications({ app, to, title, body });
50
50
  ctx.status = 204;
51
51
  }
52
+ function deserializeResource(data) {
53
+ // Extract the resource and assets from the JSON object
54
+ const { resource } = data;
55
+ const assets = data.assets;
56
+ // Function to replace asset placeholders with actual Blobs
57
+ const replaceAssets = (value) => {
58
+ if (Array.isArray(value)) {
59
+ return value.map(replaceAssets);
60
+ }
61
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
62
+ return assets[Number(value)];
63
+ }
64
+ if (value && typeof value === 'object') {
65
+ return mapValues(value, replaceAssets);
66
+ }
67
+ return value;
68
+ };
69
+ // Replace placeholders and return the deserialized resource
70
+ return replaceAssets(resource);
71
+ }
52
72
  async function handleRequestProxy(ctx, app, action, useBody, options) {
53
- var _a, _b, _c;
73
+ var _a, _b, _c, _d;
54
74
  const { method, query, request: { body, headers }, user, } = ctx;
55
75
  let data;
56
76
  if (useBody) {
57
- data = body;
77
+ if (Object.hasOwn(body, 'assets')) {
78
+ const deserializedBody = deserializeResource(body);
79
+ data = createFormData(deserializedBody.length === 1 ? deserializedBody[0] : deserializedBody);
80
+ }
81
+ else {
82
+ data = body;
83
+ }
58
84
  }
59
85
  else {
60
86
  try {
@@ -108,13 +134,41 @@ async function handleRequestProxy(ctx, app, action, useBody, options) {
108
134
  axiosConfig.validateStatus = () => true;
109
135
  axiosConfig.decompress = false;
110
136
  let response;
137
+ const urlPattern = /^http:\/\/(([\da-z-]+).){2}svc.cluster.local/;
138
+ // Restricting access to only the containers defined by the app
139
+ if (urlPattern.test(String(proxyUrl))) {
140
+ axiosConfig.url = axiosConfig.url.replace(axiosConfig.url.split('.')[1], getContainerNamespace());
141
+ const { appId } = parseServiceUrl(String(proxyUrl));
142
+ if (appId !== String(app.id)) {
143
+ throwKoaError(ctx, 403, 'Forbidden');
144
+ }
145
+ }
111
146
  logger.verbose(`Forwarding request to ${axios.getUri(axiosConfig)}`);
112
147
  try {
113
148
  response = await axios(axiosConfig);
114
149
  }
115
150
  catch (err) {
116
- logger.error(err);
117
- throwKoaError(ctx, 502, 'Bad Gateway');
151
+ // If request is sent to a companion container and fails
152
+ // Try to start it anew and retry the request
153
+ if (urlPattern.test(String(proxyUrl))) {
154
+ const { deploymentName, namespace } = parseServiceUrl(String(proxyUrl));
155
+ try {
156
+ await scaleDeployment(namespace, deploymentName, 1);
157
+ await waitForPodReadiness(namespace, deploymentName, (_d = process.env.POD_READINESS_TIMEOUT) !== null && _d !== void 0 ? _d : undefined);
158
+ response = await axios(axiosConfig);
159
+ }
160
+ catch {
161
+ logger.error(err);
162
+ throwKoaError(ctx, 502, 'Bad Gateway');
163
+ }
164
+ finally {
165
+ await setLastRequestAnnotation(namespace, deploymentName);
166
+ }
167
+ }
168
+ else {
169
+ logger.error(err);
170
+ throwKoaError(ctx, 502, 'Bad Gateway');
171
+ }
118
172
  }
119
173
  ctx.status = response.status;
120
174
  ctx.set(pick(response.headers, allowResponseHeaders));
package/server/types.d.ts CHANGED
@@ -86,6 +86,7 @@ declare module 'koas-parameters' {
86
86
  memberEmail: string;
87
87
  trainingBlockId: number;
88
88
  trainingId: number;
89
+ container: string;
89
90
  }
90
91
  interface QueryParams {
91
92
  domains: string[];