@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.
@@ -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
+ }