@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 +3 -3
- package/container/helpers.d.ts +33 -0
- package/container/helpers.js +179 -0
- package/container/index.d.ts +5 -0
- package/container/index.js +6 -0
- package/container/logs.d.ts +2 -0
- package/container/logs.js +91 -0
- package/container/operations.d.ts +15 -0
- package/container/operations.js +271 -0
- package/container/scale.d.ts +4 -0
- package/container/scale.js +117 -0
- package/container/specs.d.ts +17 -0
- package/container/specs.js +77 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +4 -3
- package/server/controllers/action.js +60 -6
- package/server/types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#  Appsemble Node Utilities
|
|
2
2
|
|
|
3
3
|
> NodeJS utilities used by Appsemble internally.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@appsemble/node-utils)
|
|
6
|
-
[](https://gitlab.com/appsemble/appsemble/-/releases/0.29.6)
|
|
7
7
|
[](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.
|
|
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,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
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsemble/node-utils",
|
|
3
|
-
"version": "0.29.
|
|
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.
|
|
42
|
-
"@appsemble/utils": "0.29.
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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));
|