@kapeta/local-cluster-service 0.0.76 → 0.1.0
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/CHANGELOG.md +7 -0
- package/definitions.d.ts +4 -4
- package/index.js +2 -2
- package/package.json +2 -3
- package/src/assetManager.js +4 -2
- package/src/containerManager.js +287 -242
- package/src/instanceManager.js +33 -9
- package/src/instances/routes.js +10 -0
- package/src/networkManager.js +6 -6
- package/src/proxy/routes.js +16 -14
- package/src/proxy/types/rest.js +5 -6
- package/src/proxy/types/web.js +3 -5
- package/src/repositoryManager.js +16 -5
- package/src/serviceManager.js +0 -1
- package/src/utils/BlockInstanceRunner.js +435 -0
- package/src/utils/LogData.js +50 -0
- package/src/utils/utils.js +13 -0
@@ -0,0 +1,435 @@
|
|
1
|
+
const {spawn} = require('node:child_process');
|
2
|
+
const FS = require('node:fs');
|
3
|
+
const Path = require('node:path');
|
4
|
+
const {Docker} = require('node-docker-api');
|
5
|
+
const ClusterConfig = require("@kapeta/local-cluster-config");
|
6
|
+
const {readYML} = require("./utils");
|
7
|
+
const {parseKapetaUri} = require("@kapeta/nodejs-utils");
|
8
|
+
const serviceManager = require("../serviceManager");
|
9
|
+
const containerManager = require("../containerManager");
|
10
|
+
const LogData = require("./LogData");
|
11
|
+
const EventEmitter = require("events");
|
12
|
+
const {execSync} = require("child_process");
|
13
|
+
|
14
|
+
const KIND_BLOCK_TYPE_OPERATOR = 'core/block-type-operator';
|
15
|
+
const KAPETA_SYSTEM_ID = "KAPETA_SYSTEM_ID";
|
16
|
+
const KAPETA_BLOCK_REF = "KAPETA_BLOCK_REF";
|
17
|
+
const KAPETA_INSTANCE_ID = "KAPETA_INSTANCE_ID";
|
18
|
+
/**
|
19
|
+
* Needed when running local docker containers as part of plan
|
20
|
+
* @type {string[]}
|
21
|
+
*/
|
22
|
+
const DOCKER_ENV_VARS = [
|
23
|
+
`KAPETA_LOCAL_SERVER=0.0.0.0`,
|
24
|
+
`KAPETA_LOCAL_CLUSTER_HOST=host.docker.internal`,
|
25
|
+
`KAPETA_ENVIRONMENT_TYPE=docker`,
|
26
|
+
]
|
27
|
+
|
28
|
+
function md5(data) {
|
29
|
+
return require('crypto').createHash('md5').update(data).digest("hex");
|
30
|
+
}
|
31
|
+
|
32
|
+
|
33
|
+
class BlockInstanceRunner {
|
34
|
+
/**
|
35
|
+
* @param {string} [planReference]
|
36
|
+
* @param {BlockInstanceInfo[]} [instances]
|
37
|
+
*/
|
38
|
+
constructor(planReference) {
|
39
|
+
/**
|
40
|
+
*
|
41
|
+
* @type {string}
|
42
|
+
* @private
|
43
|
+
*/
|
44
|
+
this._systemId = planReference ?? '';
|
45
|
+
}
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+
/**
|
50
|
+
* Start a block
|
51
|
+
*
|
52
|
+
* @param {string} blockRef
|
53
|
+
* @param {string} instanceId
|
54
|
+
* @param {any} configuration
|
55
|
+
* @returns {Promise<ProcessInfo>}
|
56
|
+
*/
|
57
|
+
async start(blockRef, instanceId, configuration) {
|
58
|
+
return this._execute({
|
59
|
+
ref: blockRef,
|
60
|
+
id: instanceId,
|
61
|
+
configuration
|
62
|
+
});
|
63
|
+
}
|
64
|
+
|
65
|
+
/**
|
66
|
+
*
|
67
|
+
* @param {BlockInstanceInfo} blockInstance
|
68
|
+
* @return {Promise<ProcessInfo>}
|
69
|
+
* @private
|
70
|
+
*/
|
71
|
+
async _execute(blockInstance) {
|
72
|
+
const env = Object.assign({}, process.env);
|
73
|
+
|
74
|
+
if (this._systemId) {
|
75
|
+
env[KAPETA_SYSTEM_ID] = this._systemId;
|
76
|
+
}
|
77
|
+
|
78
|
+
if (blockInstance.ref) {
|
79
|
+
env[KAPETA_BLOCK_REF] = blockInstance.ref;
|
80
|
+
}
|
81
|
+
|
82
|
+
if (blockInstance.id) {
|
83
|
+
env[KAPETA_INSTANCE_ID] = blockInstance.id;
|
84
|
+
}
|
85
|
+
|
86
|
+
const blockUri = parseKapetaUri(blockInstance.ref);
|
87
|
+
|
88
|
+
if (!blockUri.version) {
|
89
|
+
blockUri.version = 'local';
|
90
|
+
}
|
91
|
+
|
92
|
+
const definition = ClusterConfig.getDefinitions().find(definitions => {
|
93
|
+
const ref = `${definitions.definition.metadata.name}:${definitions.version}`
|
94
|
+
return parseKapetaUri(ref).id === blockUri.id;
|
95
|
+
});
|
96
|
+
|
97
|
+
if (!definition) {
|
98
|
+
throw new Error(`Block definition not found: ${blockUri.id}`);
|
99
|
+
}
|
100
|
+
|
101
|
+
const kindUri = parseKapetaUri(definition.definition.kind);
|
102
|
+
|
103
|
+
const provider = ClusterConfig.getProviderDefinitions().find(provider => {
|
104
|
+
const ref = `${provider.definition.metadata.name}:${provider.version}`
|
105
|
+
return parseKapetaUri(ref).id === kindUri.id;
|
106
|
+
});
|
107
|
+
|
108
|
+
if (!provider) {
|
109
|
+
throw new Error(`Kind not found: ${kindUri.id}`);
|
110
|
+
}
|
111
|
+
|
112
|
+
/**
|
113
|
+
* @type {ProcessDetails}
|
114
|
+
*/
|
115
|
+
let processDetails;
|
116
|
+
|
117
|
+
if (provider.definition.kind === KIND_BLOCK_TYPE_OPERATOR) {
|
118
|
+
processDetails = await this._startOperatorProcess(blockInstance, blockUri, provider, env);
|
119
|
+
} else {
|
120
|
+
if (blockUri.version === 'local') {
|
121
|
+
processDetails = await this._startLocalProcess(blockInstance, blockUri, env);
|
122
|
+
} else {
|
123
|
+
processDetails = await this._startDockerProcess(blockInstance, blockUri, env);
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
return {
|
128
|
+
...blockInstance,
|
129
|
+
...processDetails
|
130
|
+
};
|
131
|
+
}
|
132
|
+
|
133
|
+
|
134
|
+
/**
|
135
|
+
* Starts local process
|
136
|
+
* @param {BlockInstanceInfo} blockInstance
|
137
|
+
* @param {BlockInfo} blockInfo
|
138
|
+
* @param {EnvironmentVariables} env
|
139
|
+
* @return {ProcessDetails}
|
140
|
+
* @private
|
141
|
+
*/
|
142
|
+
_startLocalProcess(blockInstance, blockInfo, env) {
|
143
|
+
const baseDir = ClusterConfig.getRepositoryAssetPath(
|
144
|
+
blockInfo.handle,
|
145
|
+
blockInfo.name,
|
146
|
+
blockInfo.version
|
147
|
+
);
|
148
|
+
|
149
|
+
if (!FS.existsSync(baseDir)) {
|
150
|
+
throw new Error(
|
151
|
+
`Local block not registered correctly - expected symlink here: ${baseDir}.\n` +
|
152
|
+
`Make sure you've run "blockctl registry link" in your local directory to connect it to Kapeta`
|
153
|
+
);
|
154
|
+
}
|
155
|
+
|
156
|
+
const startScript = Path.resolve(baseDir, 'scripts/start.sh');
|
157
|
+
if (!FS.existsSync(startScript)) {
|
158
|
+
throw new Error(
|
159
|
+
`Start script did not exist for local block.\n` +
|
160
|
+
`Expected runnable start script here: ${startScript}`
|
161
|
+
)
|
162
|
+
}
|
163
|
+
|
164
|
+
const logs = new LogData();
|
165
|
+
const childProcess = spawn(startScript, [], {
|
166
|
+
cwd: baseDir,
|
167
|
+
env,
|
168
|
+
detached: true,
|
169
|
+
stdio: [
|
170
|
+
'pipe', 'pipe', 'pipe'
|
171
|
+
]
|
172
|
+
});
|
173
|
+
|
174
|
+
logs.addLog(`Starting block ${blockInstance.ref} using script ${startScript}`);
|
175
|
+
const outputEvents = new EventEmitter();
|
176
|
+
/**
|
177
|
+
*
|
178
|
+
* @type {ProcessDetails}
|
179
|
+
*/
|
180
|
+
const out = {
|
181
|
+
type: 'local',
|
182
|
+
pid: childProcess.pid,
|
183
|
+
output: outputEvents,
|
184
|
+
stderr: childProcess.stderr,
|
185
|
+
logs: () => {
|
186
|
+
return logs.getLogs();
|
187
|
+
},
|
188
|
+
stop: () => {
|
189
|
+
childProcess.kill('SIGTERM');
|
190
|
+
}
|
191
|
+
};
|
192
|
+
|
193
|
+
childProcess.stdout.on('data', (data) => {
|
194
|
+
logs.addLog(data.toString());
|
195
|
+
outputEvents.emit('data', data);
|
196
|
+
});
|
197
|
+
|
198
|
+
childProcess.stderr.on('data', (data) => {
|
199
|
+
logs.addLog(data.toString());
|
200
|
+
outputEvents.emit('data', data);
|
201
|
+
});
|
202
|
+
|
203
|
+
childProcess.on('exit', (code) => {
|
204
|
+
logs.addLog(`Block ${blockInstance.ref} exited with code: ${code}`);
|
205
|
+
outputEvents.emit('exit', code);
|
206
|
+
});
|
207
|
+
|
208
|
+
return out;
|
209
|
+
}
|
210
|
+
|
211
|
+
async _handleContainer(container, logs , deleteOnExit = false) {
|
212
|
+
const logStream = await container.logs({
|
213
|
+
follow: true,
|
214
|
+
stdout: true,
|
215
|
+
stderr: true,
|
216
|
+
tail: LogData.MAX_LINES
|
217
|
+
})
|
218
|
+
|
219
|
+
const outputEvents = new EventEmitter();
|
220
|
+
logStream.on('data', (data) => {
|
221
|
+
logs.addLog(data.toString());
|
222
|
+
outputEvents.emit('data', data);
|
223
|
+
});
|
224
|
+
|
225
|
+
logStream.on('error', (data) => {
|
226
|
+
logs.addLog(data.toString());
|
227
|
+
outputEvents.emit('data', data);
|
228
|
+
});
|
229
|
+
|
230
|
+
logStream.on('close', async () => {
|
231
|
+
const status = await container.status();
|
232
|
+
if (deleteOnExit) {
|
233
|
+
try {
|
234
|
+
await container.delete()
|
235
|
+
} catch (e) {}
|
236
|
+
}
|
237
|
+
outputEvents.emit('exit', status.data?.State?.ExitCode ?? 0);
|
238
|
+
});
|
239
|
+
/**
|
240
|
+
*
|
241
|
+
* @type {ProcessDetails}
|
242
|
+
*/
|
243
|
+
return {
|
244
|
+
type: 'docker',
|
245
|
+
pid: container.id,
|
246
|
+
output: outputEvents,
|
247
|
+
stop: async () => {
|
248
|
+
if (!container) {
|
249
|
+
return;
|
250
|
+
}
|
251
|
+
|
252
|
+
try {
|
253
|
+
await container.stop();
|
254
|
+
if (deleteOnExit) {
|
255
|
+
await container.delete();
|
256
|
+
}
|
257
|
+
} catch (e) {}
|
258
|
+
container = null;
|
259
|
+
},
|
260
|
+
logs: () => {
|
261
|
+
return logs.getLogs();
|
262
|
+
}
|
263
|
+
};
|
264
|
+
}
|
265
|
+
|
266
|
+
|
267
|
+
/**
|
268
|
+
* Starts local process using docker
|
269
|
+
* @param {BlockInstanceInfo} blockInstance
|
270
|
+
* @param {BlockInfo} blockInfo
|
271
|
+
* @param {EnvironmentVariables} env
|
272
|
+
* @return {Promise<ProcessDetails>}
|
273
|
+
* @private
|
274
|
+
*/
|
275
|
+
async _startDockerProcess(blockInstance, blockInfo, env) {
|
276
|
+
const {versionFile} = ClusterConfig.getRepositoryAssetInfoPath(
|
277
|
+
blockInfo.handle,
|
278
|
+
blockInfo.name,
|
279
|
+
blockInfo.version
|
280
|
+
);
|
281
|
+
|
282
|
+
const versionYml = versionFile;
|
283
|
+
if (!FS.existsSync(versionYml)) {
|
284
|
+
throw new Error(`Did not find version info at the expected path: ${versionYml}`);
|
285
|
+
}
|
286
|
+
|
287
|
+
const versionInfo = readYML(versionYml);
|
288
|
+
|
289
|
+
if (versionInfo?.artifact?.type !== 'docker') {
|
290
|
+
throw new Error(`Unsupported artifact type: ${versionInfo?.artifact?.type}`);
|
291
|
+
}
|
292
|
+
const dockerImage = versionInfo?.artifact?.details?.primary;
|
293
|
+
if (!dockerImage) {
|
294
|
+
throw new Error(`Missing docker image information: ${JSON.stringify(versionInfo?.artifact?.details)}`);
|
295
|
+
}
|
296
|
+
|
297
|
+
const containerName = `kapeta-block-instance-${blockInstance.id}`;
|
298
|
+
const logs = new LogData();
|
299
|
+
let container = await containerManager.getContainerByName(containerName);
|
300
|
+
|
301
|
+
if (container) {
|
302
|
+
if (container.data.State === 'running') {
|
303
|
+
logs.addLog(`Found existing running container for block: ${containerName}`);
|
304
|
+
} else {
|
305
|
+
logs.addLog(`Found existing container for block: ${containerName}. Starting now`);
|
306
|
+
await container.start();
|
307
|
+
}
|
308
|
+
} else {
|
309
|
+
logs.addLog(`Creating new container for block: ${containerName}`);
|
310
|
+
container = await containerManager.startContainer({
|
311
|
+
Image: dockerImage,
|
312
|
+
name: containerName,
|
313
|
+
Binds: [
|
314
|
+
`${ClusterConfig.getKapetaBasedir()}:${ClusterConfig.getKapetaBasedir()}`
|
315
|
+
],
|
316
|
+
Labels: {
|
317
|
+
'instance': blockInstance.id
|
318
|
+
},
|
319
|
+
Env: [
|
320
|
+
...DOCKER_ENV_VARS,
|
321
|
+
...Object.entries(env).map(([key, value]) => `${key}=${value}`)
|
322
|
+
]
|
323
|
+
});
|
324
|
+
}
|
325
|
+
|
326
|
+
return this._handleContainer(container, logs);
|
327
|
+
}
|
328
|
+
|
329
|
+
async _startOperatorProcess(blockInstance, blockUri, providerDefinition, env) {
|
330
|
+
const {assetFile} = ClusterConfig.getRepositoryAssetInfoPath(
|
331
|
+
blockUri.handle,
|
332
|
+
blockUri.name,
|
333
|
+
blockUri.version
|
334
|
+
);
|
335
|
+
|
336
|
+
const kapetaYmlPath = assetFile;
|
337
|
+
if (!FS.existsSync(kapetaYmlPath)) {
|
338
|
+
throw new Error(`Did not find kapeta.yml at the expected path: ${kapetaYmlPath}`);
|
339
|
+
}
|
340
|
+
|
341
|
+
const spec = providerDefinition.definition.spec;
|
342
|
+
const providerRef = `${providerDefinition.definition.metadata.name}:${providerDefinition.version}`;
|
343
|
+
|
344
|
+
if (!spec?.local?.image) {
|
345
|
+
throw new Error(`Provider did not have local image: ${providerRef}`);
|
346
|
+
}
|
347
|
+
|
348
|
+
const dockerImage = spec?.local?.image;
|
349
|
+
|
350
|
+
try {
|
351
|
+
await containerManager.pull(dockerImage);
|
352
|
+
} catch (e) {
|
353
|
+
console.warn('Failed to pull image. Continuing...', e);
|
354
|
+
}
|
355
|
+
|
356
|
+
const containerName = `kapeta-block-instance-${md5(blockInstance.id)}`;
|
357
|
+
const logs = new LogData();
|
358
|
+
let container = await containerManager.getContainerByName(containerName);
|
359
|
+
|
360
|
+
if (container) {
|
361
|
+
if (container.data.State === 'running') {
|
362
|
+
logs.addLog(`Found existing running container for block: ${containerName}`);
|
363
|
+
} else {
|
364
|
+
if (container.data.State?.ExitCode > 0) {
|
365
|
+
logs.addLog(`Container exited with code: ${container.data.State.ExitCode}. Deleting...`);
|
366
|
+
try {
|
367
|
+
await container.delete()
|
368
|
+
} catch (e) {}
|
369
|
+
container = null;
|
370
|
+
} else {
|
371
|
+
logs.addLog(`Found existing container for block: ${containerName}. Starting now`);
|
372
|
+
try {
|
373
|
+
await container.start();
|
374
|
+
} catch (e) {
|
375
|
+
console.warn('Failed to start container. Deleting...', e);
|
376
|
+
try {
|
377
|
+
await container.delete()
|
378
|
+
} catch (e) {}
|
379
|
+
container = null;
|
380
|
+
}
|
381
|
+
}
|
382
|
+
}
|
383
|
+
}
|
384
|
+
|
385
|
+
if (!container) {
|
386
|
+
const ExposedPorts = {};
|
387
|
+
const addonEnv = {};
|
388
|
+
const PortBindings = {};
|
389
|
+
const promises = Object.entries(spec.local.ports)
|
390
|
+
.map(async ([portType, value]) => {
|
391
|
+
const dockerPort = `${value.port}/${value.type}`;
|
392
|
+
ExposedPorts[dockerPort] = {};
|
393
|
+
addonEnv[`KAPETA_LOCAL_SERVER_PORT_${portType.toUpperCase()}`] = value.port;
|
394
|
+
const publicPort = await serviceManager.ensureServicePort(this._systemId, blockInstance.id, portType);
|
395
|
+
PortBindings[dockerPort] = [
|
396
|
+
{
|
397
|
+
HostIp: "127.0.0.1", //No public
|
398
|
+
HostPort: `${publicPort}`
|
399
|
+
}
|
400
|
+
];
|
401
|
+
});
|
402
|
+
|
403
|
+
await Promise.all(promises);
|
404
|
+
|
405
|
+
logs.addLog(`Creating new container for block: ${containerName}`);
|
406
|
+
container = await containerManager.startContainer({
|
407
|
+
Image: dockerImage,
|
408
|
+
name: containerName,
|
409
|
+
ExposedPorts,
|
410
|
+
HostConfig: {
|
411
|
+
Binds: [
|
412
|
+
`${kapetaYmlPath}:/kapeta.yml:ro`,
|
413
|
+
`${ClusterConfig.getKapetaBasedir()}:${ClusterConfig.getKapetaBasedir()}`
|
414
|
+
],
|
415
|
+
PortBindings
|
416
|
+
},
|
417
|
+
Labels: {
|
418
|
+
'instance': blockInstance.id
|
419
|
+
},
|
420
|
+
Env: [
|
421
|
+
`KAPETA_INSTANCE_NAME=${blockInstance.ref}`,
|
422
|
+
...DOCKER_ENV_VARS,
|
423
|
+
...Object.entries({
|
424
|
+
...env,
|
425
|
+
...addonEnv
|
426
|
+
}).map(([key, value]) => `${key}=${value}`)
|
427
|
+
]
|
428
|
+
});
|
429
|
+
}
|
430
|
+
|
431
|
+
return this._handleContainer(container, logs, true);
|
432
|
+
}
|
433
|
+
}
|
434
|
+
|
435
|
+
module.exports = BlockInstanceRunner;
|
@@ -0,0 +1,50 @@
|
|
1
|
+
const MAX_LINES = 1000;
|
2
|
+
|
3
|
+
class LogData {
|
4
|
+
|
5
|
+
constructor() {
|
6
|
+
/**
|
7
|
+
*
|
8
|
+
* @type {LogEntry[]}
|
9
|
+
*/
|
10
|
+
this.entries = [];
|
11
|
+
}
|
12
|
+
|
13
|
+
/**
|
14
|
+
*
|
15
|
+
* @param {string} msg
|
16
|
+
* @param {string} [level]
|
17
|
+
* @param {string} [source]
|
18
|
+
*/
|
19
|
+
addLog(msg, level = 'INFO', source = 'STDOUT') {
|
20
|
+
while(this.entries.length > MAX_LINES) {
|
21
|
+
this.entries.shift();
|
22
|
+
}
|
23
|
+
|
24
|
+
if (!msg.endsWith('\n')) {
|
25
|
+
msg += '\n';
|
26
|
+
}
|
27
|
+
this.entries.push({
|
28
|
+
time: Date.now(),
|
29
|
+
message: msg,
|
30
|
+
level,
|
31
|
+
source
|
32
|
+
});
|
33
|
+
}
|
34
|
+
|
35
|
+
/**
|
36
|
+
*
|
37
|
+
* @return {LogEntry[]}
|
38
|
+
*/
|
39
|
+
getLogs() {
|
40
|
+
return this.entries;
|
41
|
+
}
|
42
|
+
|
43
|
+
toString() {
|
44
|
+
return this.getLogs().map(entry => entry.message).join('\n');
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
LogData.MAX_LINES = MAX_LINES;
|
49
|
+
|
50
|
+
module.exports = LogData;
|
@@ -0,0 +1,13 @@
|
|
1
|
+
const FS = require("node:fs");
|
2
|
+
const YAML = require("yaml");
|
3
|
+
|
4
|
+
|
5
|
+
exports.readYML = function readYML(path) {
|
6
|
+
let rawYaml = FS.readFileSync(path);
|
7
|
+
|
8
|
+
try {
|
9
|
+
return YAML.parse(rawYaml.toString());
|
10
|
+
} catch(err) {
|
11
|
+
throw new Error('Failed to parse plan YAML: ' + err);
|
12
|
+
}
|
13
|
+
}
|