@kapeta/local-cluster-service 0.18.0 → 0.19.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/dist/cjs/src/containerManager.d.ts +15 -0
- package/dist/cjs/src/containerManager.js +190 -37
- package/dist/cjs/src/instanceManager.js +3 -8
- package/dist/cjs/src/socketManager.d.ts +2 -2
- package/dist/cjs/src/socketManager.js +39 -14
- package/dist/esm/src/containerManager.d.ts +15 -0
- package/dist/esm/src/containerManager.js +190 -37
- package/dist/esm/src/instanceManager.js +3 -8
- package/dist/esm/src/socketManager.d.ts +2 -2
- package/dist/esm/src/socketManager.js +39 -14
- package/package.json +1 -1
- package/src/containerManager.ts +210 -40
- package/src/instanceManager.ts +3 -10
- package/src/socketManager.ts +42 -15
package/CHANGELOG.md
CHANGED
@@ -1,3 +1,10 @@
|
|
1
|
+
# [0.19.0](https://github.com/kapetacom/local-cluster-service/compare/v0.18.0...v0.19.0) (2023-09-03)
|
2
|
+
|
3
|
+
|
4
|
+
### Features
|
5
|
+
|
6
|
+
* Listen for docker logs when user joins room ([#67](https://github.com/kapetacom/local-cluster-service/issues/67)) ([53bd6b6](https://github.com/kapetacom/local-cluster-service/commit/53bd6b6b6de27dc2f9011887176f270d60a0d9dc))
|
7
|
+
|
1
8
|
# [0.18.0](https://github.com/kapetacom/local-cluster-service/compare/v0.17.0...v0.18.0) (2023-09-02)
|
2
9
|
|
3
10
|
|
@@ -1,3 +1,5 @@
|
|
1
|
+
/// <reference types="node" />
|
2
|
+
import FSExtra from 'fs-extra';
|
1
3
|
import { Docker } from 'node-docker-api';
|
2
4
|
import { Container } from 'node-docker-api/lib/container';
|
3
5
|
import { InstanceInfo, LogEntry } from './types';
|
@@ -50,6 +52,7 @@ declare class ContainerManager {
|
|
50
52
|
private _mountDir;
|
51
53
|
private _version;
|
52
54
|
private _lastDockerAccessCheck;
|
55
|
+
private logStreams;
|
53
56
|
constructor();
|
54
57
|
initialize(): Promise<void>;
|
55
58
|
checkAlive(): Promise<boolean>;
|
@@ -83,6 +86,17 @@ declare class ContainerManager {
|
|
83
86
|
*/
|
84
87
|
get(name: string): Promise<ContainerInfo | null>;
|
85
88
|
getLogs(instance: InstanceInfo): Promise<LogEntry[]>;
|
89
|
+
stopLogListening(systemId: string, instanceId: string): Promise<void>;
|
90
|
+
ensureLogListening(systemId: string, instanceId: string, handler: (log: LogEntry) => void): Promise<void>;
|
91
|
+
}
|
92
|
+
declare class ClosableLogStream {
|
93
|
+
private readonly stream;
|
94
|
+
private readonly eventEmitter;
|
95
|
+
constructor(stream: FSExtra.ReadStream);
|
96
|
+
onLog(listener: (log: LogEntry) => void): () => void;
|
97
|
+
onEnd(listener: () => void): () => void;
|
98
|
+
onError(listener: (error: Error) => void): () => void;
|
99
|
+
close(): Promise<void>;
|
86
100
|
}
|
87
101
|
export declare class ContainerInfo {
|
88
102
|
private readonly _container;
|
@@ -107,6 +121,7 @@ export declare class ContainerInfo {
|
|
107
121
|
inspect(): Promise<any>;
|
108
122
|
status(): Promise<DockerState>;
|
109
123
|
getPorts(): Promise<PortMap | false>;
|
124
|
+
getLogStream(): Promise<ClosableLogStream>;
|
110
125
|
getLogs(): Promise<LogEntry[]>;
|
111
126
|
}
|
112
127
|
export declare function getExtraHosts(dockerVersion: string): string[] | undefined;
|
@@ -17,6 +17,7 @@ const md5_1 = __importDefault(require("md5"));
|
|
17
17
|
const utils_1 = require("./utils/utils");
|
18
18
|
const nodejs_api_client_1 = require("@kapeta/nodejs-api-client");
|
19
19
|
const taskManager_1 = require("./taskManager");
|
20
|
+
const node_events_1 = require("node:events");
|
20
21
|
exports.CONTAINER_LABEL_PORT_PREFIX = 'kapeta_port-';
|
21
22
|
const NANO_SECOND = 1000000;
|
22
23
|
const HEALTH_CHECK_INTERVAL = 3000;
|
@@ -33,6 +34,7 @@ class ContainerManager {
|
|
33
34
|
_mountDir;
|
34
35
|
_version;
|
35
36
|
_lastDockerAccessCheck = 0;
|
37
|
+
logStreams = {};
|
36
38
|
constructor() {
|
37
39
|
this._docker = null;
|
38
40
|
this._alive = false;
|
@@ -472,6 +474,175 @@ class ContainerManager {
|
|
472
474
|
}
|
473
475
|
return containerInfo.getLogs();
|
474
476
|
}
|
477
|
+
async stopLogListening(systemId, instanceId) {
|
478
|
+
const containerName = (0, utils_1.getBlockInstanceContainerName)(systemId, instanceId);
|
479
|
+
if (this.logStreams[containerName]) {
|
480
|
+
if (this.logStreams[containerName]?.timer) {
|
481
|
+
clearTimeout(this.logStreams[containerName].timer);
|
482
|
+
}
|
483
|
+
console.log('Stopped listening for logs on container: %s', containerName);
|
484
|
+
try {
|
485
|
+
const stream = this.logStreams[containerName].stream;
|
486
|
+
if (stream) {
|
487
|
+
await stream.close();
|
488
|
+
}
|
489
|
+
}
|
490
|
+
catch (err) {
|
491
|
+
// Ignore
|
492
|
+
}
|
493
|
+
delete this.logStreams[containerName];
|
494
|
+
}
|
495
|
+
}
|
496
|
+
async ensureLogListening(systemId, instanceId, handler) {
|
497
|
+
const containerName = (0, utils_1.getBlockInstanceContainerName)(systemId, instanceId);
|
498
|
+
try {
|
499
|
+
if (this.logStreams[containerName]?.stream) {
|
500
|
+
// Already listening - will shut itself down
|
501
|
+
return;
|
502
|
+
}
|
503
|
+
if (this.logStreams[containerName]?.timer) {
|
504
|
+
clearTimeout(this.logStreams[containerName].timer);
|
505
|
+
}
|
506
|
+
const tryLater = () => {
|
507
|
+
this.logStreams[containerName] = {
|
508
|
+
timer: setTimeout(() => {
|
509
|
+
// Keep trying until user decides to not listen anymore
|
510
|
+
this.ensureLogListening(systemId, instanceId, handler);
|
511
|
+
}, 5000),
|
512
|
+
};
|
513
|
+
};
|
514
|
+
const containerInfo = await this.getContainerByName(containerName);
|
515
|
+
if (!containerInfo || !(await containerInfo.isRunning())) {
|
516
|
+
// Container not currently running - try again in 5 seconds
|
517
|
+
tryLater();
|
518
|
+
return;
|
519
|
+
}
|
520
|
+
const stream = await containerInfo.getLogStream();
|
521
|
+
stream.onLog((log) => {
|
522
|
+
try {
|
523
|
+
handler(log);
|
524
|
+
}
|
525
|
+
catch (err) {
|
526
|
+
console.warn('Error handling log', err);
|
527
|
+
}
|
528
|
+
});
|
529
|
+
stream.onEnd(() => {
|
530
|
+
// We get here if the container is stopped
|
531
|
+
delete this.logStreams[containerName];
|
532
|
+
tryLater();
|
533
|
+
});
|
534
|
+
stream.onError((err) => {
|
535
|
+
// We get here if the container crashes
|
536
|
+
delete this.logStreams[containerName];
|
537
|
+
tryLater();
|
538
|
+
});
|
539
|
+
this.logStreams[containerName] = {
|
540
|
+
stream,
|
541
|
+
};
|
542
|
+
}
|
543
|
+
catch (err) {
|
544
|
+
// Ignore
|
545
|
+
}
|
546
|
+
}
|
547
|
+
}
|
548
|
+
function readLogBuffer(logBuffer) {
|
549
|
+
const out = [];
|
550
|
+
let offset = 0;
|
551
|
+
while (offset < logBuffer.length) {
|
552
|
+
try {
|
553
|
+
// Read the docker log format - explained here:
|
554
|
+
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
555
|
+
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
556
|
+
// First byte is stream type
|
557
|
+
const streamTypeInt = logBuffer.readInt8(offset);
|
558
|
+
const streamType = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
559
|
+
if (streamTypeInt !== 1 && streamTypeInt !== 2) {
|
560
|
+
console.error('Unknown stream type: %s', streamTypeInt, out[out.length - 1]);
|
561
|
+
break;
|
562
|
+
}
|
563
|
+
// Bytes 4-8 is frame size
|
564
|
+
const messageLength = logBuffer.readInt32BE(offset + 4);
|
565
|
+
// After that is the message - with the message length
|
566
|
+
const dataWithoutStreamType = logBuffer.subarray(offset + 8, offset + 8 + messageLength);
|
567
|
+
const raw = dataWithoutStreamType.toString();
|
568
|
+
// Split the message into date and message
|
569
|
+
const firstSpaceIx = raw.indexOf(' ');
|
570
|
+
const dateString = raw.substring(0, firstSpaceIx);
|
571
|
+
const line = raw.substring(firstSpaceIx + 1);
|
572
|
+
offset = offset + messageLength + 8;
|
573
|
+
if (!dateString) {
|
574
|
+
break;
|
575
|
+
}
|
576
|
+
out.push({
|
577
|
+
time: new Date(dateString).getTime(),
|
578
|
+
message: line,
|
579
|
+
level: 'INFO',
|
580
|
+
source: streamType,
|
581
|
+
});
|
582
|
+
}
|
583
|
+
catch (err) {
|
584
|
+
console.error('Error parsing log entry', err);
|
585
|
+
offset = logBuffer.length;
|
586
|
+
break;
|
587
|
+
}
|
588
|
+
}
|
589
|
+
return out;
|
590
|
+
}
|
591
|
+
class ClosableLogStream {
|
592
|
+
stream;
|
593
|
+
eventEmitter;
|
594
|
+
constructor(stream) {
|
595
|
+
this.stream = stream;
|
596
|
+
this.eventEmitter = new node_events_1.EventEmitter();
|
597
|
+
stream.on('data', (data) => {
|
598
|
+
const logs = readLogBuffer(data);
|
599
|
+
logs.forEach((log) => {
|
600
|
+
this.eventEmitter.emit('log', log);
|
601
|
+
});
|
602
|
+
});
|
603
|
+
stream.on('end', () => {
|
604
|
+
this.eventEmitter.emit('end');
|
605
|
+
});
|
606
|
+
stream.on('error', (error) => {
|
607
|
+
this.eventEmitter.emit('error', error);
|
608
|
+
});
|
609
|
+
stream.on('close', () => {
|
610
|
+
this.eventEmitter.emit('end');
|
611
|
+
});
|
612
|
+
}
|
613
|
+
onLog(listener) {
|
614
|
+
this.eventEmitter.on('log', listener);
|
615
|
+
return () => {
|
616
|
+
this.eventEmitter.removeListener('log', listener);
|
617
|
+
};
|
618
|
+
}
|
619
|
+
onEnd(listener) {
|
620
|
+
this.eventEmitter.on('end', listener);
|
621
|
+
return () => {
|
622
|
+
this.eventEmitter.removeListener('end', listener);
|
623
|
+
};
|
624
|
+
}
|
625
|
+
onError(listener) {
|
626
|
+
this.eventEmitter.on('error', listener);
|
627
|
+
return () => {
|
628
|
+
this.eventEmitter.removeListener('error', listener);
|
629
|
+
};
|
630
|
+
}
|
631
|
+
close() {
|
632
|
+
return new Promise((resolve, reject) => {
|
633
|
+
try {
|
634
|
+
this.stream.close((err) => {
|
635
|
+
if (err) {
|
636
|
+
console.warn('Error closing log stream', err);
|
637
|
+
}
|
638
|
+
resolve();
|
639
|
+
});
|
640
|
+
}
|
641
|
+
catch (err) {
|
642
|
+
// Ignore
|
643
|
+
}
|
644
|
+
});
|
645
|
+
}
|
475
646
|
}
|
476
647
|
class ContainerInfo {
|
477
648
|
_container;
|
@@ -555,52 +726,34 @@ class ContainerInfo {
|
|
555
726
|
});
|
556
727
|
return ports;
|
557
728
|
}
|
729
|
+
async getLogStream() {
|
730
|
+
try {
|
731
|
+
const logStream = (await this.native.logs({
|
732
|
+
stdout: true,
|
733
|
+
stderr: true,
|
734
|
+
follow: true,
|
735
|
+
tail: 0,
|
736
|
+
timestamps: true,
|
737
|
+
}));
|
738
|
+
return new ClosableLogStream(logStream);
|
739
|
+
}
|
740
|
+
catch (err) {
|
741
|
+
console.log('Error getting log stream', err);
|
742
|
+
throw err;
|
743
|
+
}
|
744
|
+
}
|
558
745
|
async getLogs() {
|
559
746
|
const logStream = (await this.native.logs({
|
560
747
|
stdout: true,
|
561
748
|
stderr: true,
|
562
749
|
follow: false,
|
563
|
-
tail: 100,
|
564
750
|
timestamps: true,
|
565
751
|
}));
|
566
|
-
const
|
752
|
+
const chunks = [];
|
567
753
|
await promisifyStream(logStream, (data) => {
|
568
|
-
|
569
|
-
let offset = 0;
|
570
|
-
while (offset < buf.length) {
|
571
|
-
try {
|
572
|
-
// Read the docker log format - explained here:
|
573
|
-
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
574
|
-
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
575
|
-
// First byte is stream type
|
576
|
-
const streamTypeInt = buf.readInt8(offset);
|
577
|
-
const streamType = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
578
|
-
// Bytes 4-8 is frame size
|
579
|
-
const messageLength = buf.readInt32BE(offset + 4);
|
580
|
-
// After that is the message - with the message length
|
581
|
-
const dataWithoutStreamType = buf.subarray(offset + 8, offset + 8 + messageLength);
|
582
|
-
const raw = dataWithoutStreamType.toString();
|
583
|
-
// Split the message into date and message
|
584
|
-
const firstSpaceIx = raw.indexOf(' ');
|
585
|
-
const dateString = raw.substring(0, firstSpaceIx);
|
586
|
-
const line = raw.substring(firstSpaceIx + 1);
|
587
|
-
offset = offset + messageLength + 8;
|
588
|
-
if (!dateString) {
|
589
|
-
continue;
|
590
|
-
}
|
591
|
-
out.push({
|
592
|
-
time: new Date(dateString).getTime(),
|
593
|
-
message: line,
|
594
|
-
level: 'INFO',
|
595
|
-
source: streamType,
|
596
|
-
});
|
597
|
-
}
|
598
|
-
catch (err) {
|
599
|
-
console.error('Error parsing log entry', err);
|
600
|
-
offset = buf.length;
|
601
|
-
}
|
602
|
-
}
|
754
|
+
chunks.push(data);
|
603
755
|
});
|
756
|
+
const out = readLogBuffer(Buffer.concat(chunks));
|
604
757
|
if (out.length === 0) {
|
605
758
|
out.push({
|
606
759
|
time: Date.now(),
|
@@ -422,11 +422,6 @@ class InstanceManager {
|
|
422
422
|
errorMessage: e.message ?? 'Failed to start - Check logs for details.',
|
423
423
|
});
|
424
424
|
socketManager_1.socketManager.emitInstanceLog(systemId, instanceId, logs[0]);
|
425
|
-
socketManager_1.socketManager.emitInstanceEvent(systemId, blockInstance.id, socketManager_1.EVENT_INSTANCE_EXITED, {
|
426
|
-
error: `Failed to start instance: ${e.message}`,
|
427
|
-
status: socketManager_1.EVENT_INSTANCE_EXITED,
|
428
|
-
instanceId: blockInstance.id,
|
429
|
-
});
|
430
425
|
return out;
|
431
426
|
}
|
432
427
|
}, {
|
@@ -486,6 +481,7 @@ class InstanceManager {
|
|
486
481
|
try {
|
487
482
|
const plan = await assetManager_1.assetManager.getAsset(instance.systemId, true, false);
|
488
483
|
if (!plan) {
|
484
|
+
console.log('Plan not found - reset to stop', instance.ref, instance.systemId);
|
489
485
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
490
486
|
changed = true;
|
491
487
|
return;
|
@@ -493,12 +489,14 @@ class InstanceManager {
|
|
493
489
|
const planData = plan.data;
|
494
490
|
const planInstance = planData?.spec?.blocks?.find((b) => b.id === instance.instanceId);
|
495
491
|
if (!planInstance || !planInstance?.block?.ref) {
|
492
|
+
console.log('Plan instance not found - reset to stop', instance.ref, instance.systemId);
|
496
493
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
497
494
|
changed = true;
|
498
495
|
return;
|
499
496
|
}
|
500
497
|
const blockDef = await assetManager_1.assetManager.getAsset(instance.ref, true, false);
|
501
498
|
if (!blockDef) {
|
499
|
+
console.log('Block definition not found - reset to stop', instance.ref, instance.systemId);
|
502
500
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
503
501
|
changed = true;
|
504
502
|
return;
|
@@ -676,6 +674,3 @@ class InstanceManager {
|
|
676
674
|
}
|
677
675
|
exports.InstanceManager = InstanceManager;
|
678
676
|
exports.instanceManager = new InstanceManager();
|
679
|
-
process.on('exit', async () => {
|
680
|
-
await exports.instanceManager.stopAll();
|
681
|
-
});
|
@@ -19,10 +19,10 @@ export declare class SocketManager {
|
|
19
19
|
emitInstanceLog(systemId: string, instanceId: string, payload: LogEntry): void;
|
20
20
|
emitSystemLog(systemId: string, payload: LogEntry): void;
|
21
21
|
emitGlobalLog(payload: LogEntry): void;
|
22
|
-
emitInstanceEvent(systemId: string, instanceId: string, type: string, payload: any): void;
|
23
22
|
private _bindIO;
|
24
23
|
private _handleSocketCreated;
|
25
24
|
private _bindSocket;
|
26
|
-
private
|
25
|
+
private handleJoinRoom;
|
26
|
+
private handleLeaveRoom;
|
27
27
|
}
|
28
28
|
export declare const socketManager: SocketManager;
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.socketManager = exports.SocketManager = exports.EVENT_LOG = exports.EVENT_SYSTEM_LOG = exports.EVENT_INSTANCE_LOG = exports.EVENT_INSTANCE_EXITED = exports.EVENT_INSTANCE_CREATED = exports.EVENT_STATUS_CHANGED = void 0;
|
7
7
|
const lodash_1 = __importDefault(require("lodash"));
|
8
8
|
const utils_1 = require("./utils/utils");
|
9
|
+
const containerManager_1 = require("./containerManager");
|
9
10
|
exports.EVENT_STATUS_CHANGED = 'status-changed';
|
10
11
|
exports.EVENT_INSTANCE_CREATED = 'instance-created';
|
11
12
|
exports.EVENT_INSTANCE_EXITED = 'instance-exited';
|
@@ -48,30 +49,28 @@ class SocketManager {
|
|
48
49
|
emitSystemEvent(systemId, type, payload) {
|
49
50
|
systemId = (0, utils_1.normalizeKapetaUri)(systemId);
|
50
51
|
try {
|
51
|
-
|
52
|
+
const contextId = `system-events/${encodeURIComponent(systemId)}`;
|
53
|
+
this.emit(contextId, type, payload);
|
52
54
|
}
|
53
55
|
catch (e) {
|
54
56
|
console.warn('Failed to emit instance event: %s', e.message);
|
55
57
|
}
|
56
58
|
}
|
57
59
|
emitInstanceLog(systemId, instanceId, payload) {
|
58
|
-
this.emitInstanceEvent(systemId, instanceId, exports.EVENT_INSTANCE_LOG, payload);
|
59
|
-
}
|
60
|
-
emitSystemLog(systemId, payload) {
|
61
|
-
this.emitSystemEvent(systemId, exports.EVENT_SYSTEM_LOG, payload);
|
62
|
-
}
|
63
|
-
emitGlobalLog(payload) {
|
64
|
-
this.emitGlobal(exports.EVENT_LOG, payload);
|
65
|
-
}
|
66
|
-
emitInstanceEvent(systemId, instanceId, type, payload) {
|
67
60
|
systemId = (0, utils_1.normalizeKapetaUri)(systemId);
|
68
61
|
try {
|
69
|
-
|
62
|
+
this.emit(`instance-logs/${encodeURIComponent(systemId)}/${encodeURIComponent(instanceId)}`, exports.EVENT_INSTANCE_LOG, payload);
|
70
63
|
}
|
71
64
|
catch (e) {
|
72
65
|
console.warn('Failed to emit instance event: %s', e.message);
|
73
66
|
}
|
74
67
|
}
|
68
|
+
emitSystemLog(systemId, payload) {
|
69
|
+
this.emitSystemEvent(systemId, exports.EVENT_SYSTEM_LOG, payload);
|
70
|
+
}
|
71
|
+
emitGlobalLog(payload) {
|
72
|
+
this.emitGlobal(exports.EVENT_LOG, payload);
|
73
|
+
}
|
75
74
|
_bindIO() {
|
76
75
|
this.io.on('connection', (socket) => this._handleSocketCreated(socket));
|
77
76
|
}
|
@@ -80,16 +79,42 @@ class SocketManager {
|
|
80
79
|
this._sockets.push(socket);
|
81
80
|
}
|
82
81
|
_bindSocket(socket) {
|
83
|
-
socket.on('disconnect', () =>
|
82
|
+
socket.on('disconnect', () => {
|
83
|
+
lodash_1.default.pull(this._sockets, socket);
|
84
|
+
socket.rooms.forEach((roomId) => {
|
85
|
+
this.handleLeaveRoom(roomId);
|
86
|
+
});
|
87
|
+
});
|
84
88
|
socket.on('join', (id) => {
|
85
89
|
socket.join(id);
|
90
|
+
this.handleJoinRoom(id);
|
86
91
|
});
|
87
92
|
socket.on('leave', (id) => {
|
88
93
|
socket.leave(id);
|
94
|
+
this.handleLeaveRoom(id);
|
89
95
|
});
|
90
96
|
}
|
91
|
-
|
92
|
-
|
97
|
+
handleJoinRoom(id) {
|
98
|
+
if (id.startsWith('instance-logs/')) {
|
99
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
100
|
+
systemId = decodeURIComponent(systemId);
|
101
|
+
instanceId = decodeURIComponent(instanceId);
|
102
|
+
console.log('Start listening for logs', systemId, instanceId);
|
103
|
+
containerManager_1.containerManager
|
104
|
+
.ensureLogListening(systemId, instanceId, (log) => {
|
105
|
+
this.emitInstanceLog(systemId, instanceId, log);
|
106
|
+
})
|
107
|
+
.catch((e) => { });
|
108
|
+
}
|
109
|
+
}
|
110
|
+
handleLeaveRoom(id) {
|
111
|
+
if (id.startsWith('instance-logs/')) {
|
112
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
113
|
+
systemId = decodeURIComponent(systemId);
|
114
|
+
instanceId = decodeURIComponent(instanceId);
|
115
|
+
console.log('Stop listening for logs', systemId, instanceId);
|
116
|
+
containerManager_1.containerManager.stopLogListening(systemId, instanceId).catch((e) => { });
|
117
|
+
}
|
93
118
|
}
|
94
119
|
}
|
95
120
|
exports.SocketManager = SocketManager;
|
@@ -1,3 +1,5 @@
|
|
1
|
+
/// <reference types="node" />
|
2
|
+
import FSExtra from 'fs-extra';
|
1
3
|
import { Docker } from 'node-docker-api';
|
2
4
|
import { Container } from 'node-docker-api/lib/container';
|
3
5
|
import { InstanceInfo, LogEntry } from './types';
|
@@ -50,6 +52,7 @@ declare class ContainerManager {
|
|
50
52
|
private _mountDir;
|
51
53
|
private _version;
|
52
54
|
private _lastDockerAccessCheck;
|
55
|
+
private logStreams;
|
53
56
|
constructor();
|
54
57
|
initialize(): Promise<void>;
|
55
58
|
checkAlive(): Promise<boolean>;
|
@@ -83,6 +86,17 @@ declare class ContainerManager {
|
|
83
86
|
*/
|
84
87
|
get(name: string): Promise<ContainerInfo | null>;
|
85
88
|
getLogs(instance: InstanceInfo): Promise<LogEntry[]>;
|
89
|
+
stopLogListening(systemId: string, instanceId: string): Promise<void>;
|
90
|
+
ensureLogListening(systemId: string, instanceId: string, handler: (log: LogEntry) => void): Promise<void>;
|
91
|
+
}
|
92
|
+
declare class ClosableLogStream {
|
93
|
+
private readonly stream;
|
94
|
+
private readonly eventEmitter;
|
95
|
+
constructor(stream: FSExtra.ReadStream);
|
96
|
+
onLog(listener: (log: LogEntry) => void): () => void;
|
97
|
+
onEnd(listener: () => void): () => void;
|
98
|
+
onError(listener: (error: Error) => void): () => void;
|
99
|
+
close(): Promise<void>;
|
86
100
|
}
|
87
101
|
export declare class ContainerInfo {
|
88
102
|
private readonly _container;
|
@@ -107,6 +121,7 @@ export declare class ContainerInfo {
|
|
107
121
|
inspect(): Promise<any>;
|
108
122
|
status(): Promise<DockerState>;
|
109
123
|
getPorts(): Promise<PortMap | false>;
|
124
|
+
getLogStream(): Promise<ClosableLogStream>;
|
110
125
|
getLogs(): Promise<LogEntry[]>;
|
111
126
|
}
|
112
127
|
export declare function getExtraHosts(dockerVersion: string): string[] | undefined;
|
@@ -17,6 +17,7 @@ const md5_1 = __importDefault(require("md5"));
|
|
17
17
|
const utils_1 = require("./utils/utils");
|
18
18
|
const nodejs_api_client_1 = require("@kapeta/nodejs-api-client");
|
19
19
|
const taskManager_1 = require("./taskManager");
|
20
|
+
const node_events_1 = require("node:events");
|
20
21
|
exports.CONTAINER_LABEL_PORT_PREFIX = 'kapeta_port-';
|
21
22
|
const NANO_SECOND = 1000000;
|
22
23
|
const HEALTH_CHECK_INTERVAL = 3000;
|
@@ -33,6 +34,7 @@ class ContainerManager {
|
|
33
34
|
_mountDir;
|
34
35
|
_version;
|
35
36
|
_lastDockerAccessCheck = 0;
|
37
|
+
logStreams = {};
|
36
38
|
constructor() {
|
37
39
|
this._docker = null;
|
38
40
|
this._alive = false;
|
@@ -472,6 +474,175 @@ class ContainerManager {
|
|
472
474
|
}
|
473
475
|
return containerInfo.getLogs();
|
474
476
|
}
|
477
|
+
async stopLogListening(systemId, instanceId) {
|
478
|
+
const containerName = (0, utils_1.getBlockInstanceContainerName)(systemId, instanceId);
|
479
|
+
if (this.logStreams[containerName]) {
|
480
|
+
if (this.logStreams[containerName]?.timer) {
|
481
|
+
clearTimeout(this.logStreams[containerName].timer);
|
482
|
+
}
|
483
|
+
console.log('Stopped listening for logs on container: %s', containerName);
|
484
|
+
try {
|
485
|
+
const stream = this.logStreams[containerName].stream;
|
486
|
+
if (stream) {
|
487
|
+
await stream.close();
|
488
|
+
}
|
489
|
+
}
|
490
|
+
catch (err) {
|
491
|
+
// Ignore
|
492
|
+
}
|
493
|
+
delete this.logStreams[containerName];
|
494
|
+
}
|
495
|
+
}
|
496
|
+
async ensureLogListening(systemId, instanceId, handler) {
|
497
|
+
const containerName = (0, utils_1.getBlockInstanceContainerName)(systemId, instanceId);
|
498
|
+
try {
|
499
|
+
if (this.logStreams[containerName]?.stream) {
|
500
|
+
// Already listening - will shut itself down
|
501
|
+
return;
|
502
|
+
}
|
503
|
+
if (this.logStreams[containerName]?.timer) {
|
504
|
+
clearTimeout(this.logStreams[containerName].timer);
|
505
|
+
}
|
506
|
+
const tryLater = () => {
|
507
|
+
this.logStreams[containerName] = {
|
508
|
+
timer: setTimeout(() => {
|
509
|
+
// Keep trying until user decides to not listen anymore
|
510
|
+
this.ensureLogListening(systemId, instanceId, handler);
|
511
|
+
}, 5000),
|
512
|
+
};
|
513
|
+
};
|
514
|
+
const containerInfo = await this.getContainerByName(containerName);
|
515
|
+
if (!containerInfo || !(await containerInfo.isRunning())) {
|
516
|
+
// Container not currently running - try again in 5 seconds
|
517
|
+
tryLater();
|
518
|
+
return;
|
519
|
+
}
|
520
|
+
const stream = await containerInfo.getLogStream();
|
521
|
+
stream.onLog((log) => {
|
522
|
+
try {
|
523
|
+
handler(log);
|
524
|
+
}
|
525
|
+
catch (err) {
|
526
|
+
console.warn('Error handling log', err);
|
527
|
+
}
|
528
|
+
});
|
529
|
+
stream.onEnd(() => {
|
530
|
+
// We get here if the container is stopped
|
531
|
+
delete this.logStreams[containerName];
|
532
|
+
tryLater();
|
533
|
+
});
|
534
|
+
stream.onError((err) => {
|
535
|
+
// We get here if the container crashes
|
536
|
+
delete this.logStreams[containerName];
|
537
|
+
tryLater();
|
538
|
+
});
|
539
|
+
this.logStreams[containerName] = {
|
540
|
+
stream,
|
541
|
+
};
|
542
|
+
}
|
543
|
+
catch (err) {
|
544
|
+
// Ignore
|
545
|
+
}
|
546
|
+
}
|
547
|
+
}
|
548
|
+
function readLogBuffer(logBuffer) {
|
549
|
+
const out = [];
|
550
|
+
let offset = 0;
|
551
|
+
while (offset < logBuffer.length) {
|
552
|
+
try {
|
553
|
+
// Read the docker log format - explained here:
|
554
|
+
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
555
|
+
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
556
|
+
// First byte is stream type
|
557
|
+
const streamTypeInt = logBuffer.readInt8(offset);
|
558
|
+
const streamType = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
559
|
+
if (streamTypeInt !== 1 && streamTypeInt !== 2) {
|
560
|
+
console.error('Unknown stream type: %s', streamTypeInt, out[out.length - 1]);
|
561
|
+
break;
|
562
|
+
}
|
563
|
+
// Bytes 4-8 is frame size
|
564
|
+
const messageLength = logBuffer.readInt32BE(offset + 4);
|
565
|
+
// After that is the message - with the message length
|
566
|
+
const dataWithoutStreamType = logBuffer.subarray(offset + 8, offset + 8 + messageLength);
|
567
|
+
const raw = dataWithoutStreamType.toString();
|
568
|
+
// Split the message into date and message
|
569
|
+
const firstSpaceIx = raw.indexOf(' ');
|
570
|
+
const dateString = raw.substring(0, firstSpaceIx);
|
571
|
+
const line = raw.substring(firstSpaceIx + 1);
|
572
|
+
offset = offset + messageLength + 8;
|
573
|
+
if (!dateString) {
|
574
|
+
break;
|
575
|
+
}
|
576
|
+
out.push({
|
577
|
+
time: new Date(dateString).getTime(),
|
578
|
+
message: line,
|
579
|
+
level: 'INFO',
|
580
|
+
source: streamType,
|
581
|
+
});
|
582
|
+
}
|
583
|
+
catch (err) {
|
584
|
+
console.error('Error parsing log entry', err);
|
585
|
+
offset = logBuffer.length;
|
586
|
+
break;
|
587
|
+
}
|
588
|
+
}
|
589
|
+
return out;
|
590
|
+
}
|
591
|
+
class ClosableLogStream {
|
592
|
+
stream;
|
593
|
+
eventEmitter;
|
594
|
+
constructor(stream) {
|
595
|
+
this.stream = stream;
|
596
|
+
this.eventEmitter = new node_events_1.EventEmitter();
|
597
|
+
stream.on('data', (data) => {
|
598
|
+
const logs = readLogBuffer(data);
|
599
|
+
logs.forEach((log) => {
|
600
|
+
this.eventEmitter.emit('log', log);
|
601
|
+
});
|
602
|
+
});
|
603
|
+
stream.on('end', () => {
|
604
|
+
this.eventEmitter.emit('end');
|
605
|
+
});
|
606
|
+
stream.on('error', (error) => {
|
607
|
+
this.eventEmitter.emit('error', error);
|
608
|
+
});
|
609
|
+
stream.on('close', () => {
|
610
|
+
this.eventEmitter.emit('end');
|
611
|
+
});
|
612
|
+
}
|
613
|
+
onLog(listener) {
|
614
|
+
this.eventEmitter.on('log', listener);
|
615
|
+
return () => {
|
616
|
+
this.eventEmitter.removeListener('log', listener);
|
617
|
+
};
|
618
|
+
}
|
619
|
+
onEnd(listener) {
|
620
|
+
this.eventEmitter.on('end', listener);
|
621
|
+
return () => {
|
622
|
+
this.eventEmitter.removeListener('end', listener);
|
623
|
+
};
|
624
|
+
}
|
625
|
+
onError(listener) {
|
626
|
+
this.eventEmitter.on('error', listener);
|
627
|
+
return () => {
|
628
|
+
this.eventEmitter.removeListener('error', listener);
|
629
|
+
};
|
630
|
+
}
|
631
|
+
close() {
|
632
|
+
return new Promise((resolve, reject) => {
|
633
|
+
try {
|
634
|
+
this.stream.close((err) => {
|
635
|
+
if (err) {
|
636
|
+
console.warn('Error closing log stream', err);
|
637
|
+
}
|
638
|
+
resolve();
|
639
|
+
});
|
640
|
+
}
|
641
|
+
catch (err) {
|
642
|
+
// Ignore
|
643
|
+
}
|
644
|
+
});
|
645
|
+
}
|
475
646
|
}
|
476
647
|
class ContainerInfo {
|
477
648
|
_container;
|
@@ -555,52 +726,34 @@ class ContainerInfo {
|
|
555
726
|
});
|
556
727
|
return ports;
|
557
728
|
}
|
729
|
+
async getLogStream() {
|
730
|
+
try {
|
731
|
+
const logStream = (await this.native.logs({
|
732
|
+
stdout: true,
|
733
|
+
stderr: true,
|
734
|
+
follow: true,
|
735
|
+
tail: 0,
|
736
|
+
timestamps: true,
|
737
|
+
}));
|
738
|
+
return new ClosableLogStream(logStream);
|
739
|
+
}
|
740
|
+
catch (err) {
|
741
|
+
console.log('Error getting log stream', err);
|
742
|
+
throw err;
|
743
|
+
}
|
744
|
+
}
|
558
745
|
async getLogs() {
|
559
746
|
const logStream = (await this.native.logs({
|
560
747
|
stdout: true,
|
561
748
|
stderr: true,
|
562
749
|
follow: false,
|
563
|
-
tail: 100,
|
564
750
|
timestamps: true,
|
565
751
|
}));
|
566
|
-
const
|
752
|
+
const chunks = [];
|
567
753
|
await promisifyStream(logStream, (data) => {
|
568
|
-
|
569
|
-
let offset = 0;
|
570
|
-
while (offset < buf.length) {
|
571
|
-
try {
|
572
|
-
// Read the docker log format - explained here:
|
573
|
-
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
574
|
-
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
575
|
-
// First byte is stream type
|
576
|
-
const streamTypeInt = buf.readInt8(offset);
|
577
|
-
const streamType = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
578
|
-
// Bytes 4-8 is frame size
|
579
|
-
const messageLength = buf.readInt32BE(offset + 4);
|
580
|
-
// After that is the message - with the message length
|
581
|
-
const dataWithoutStreamType = buf.subarray(offset + 8, offset + 8 + messageLength);
|
582
|
-
const raw = dataWithoutStreamType.toString();
|
583
|
-
// Split the message into date and message
|
584
|
-
const firstSpaceIx = raw.indexOf(' ');
|
585
|
-
const dateString = raw.substring(0, firstSpaceIx);
|
586
|
-
const line = raw.substring(firstSpaceIx + 1);
|
587
|
-
offset = offset + messageLength + 8;
|
588
|
-
if (!dateString) {
|
589
|
-
continue;
|
590
|
-
}
|
591
|
-
out.push({
|
592
|
-
time: new Date(dateString).getTime(),
|
593
|
-
message: line,
|
594
|
-
level: 'INFO',
|
595
|
-
source: streamType,
|
596
|
-
});
|
597
|
-
}
|
598
|
-
catch (err) {
|
599
|
-
console.error('Error parsing log entry', err);
|
600
|
-
offset = buf.length;
|
601
|
-
}
|
602
|
-
}
|
754
|
+
chunks.push(data);
|
603
755
|
});
|
756
|
+
const out = readLogBuffer(Buffer.concat(chunks));
|
604
757
|
if (out.length === 0) {
|
605
758
|
out.push({
|
606
759
|
time: Date.now(),
|
@@ -422,11 +422,6 @@ class InstanceManager {
|
|
422
422
|
errorMessage: e.message ?? 'Failed to start - Check logs for details.',
|
423
423
|
});
|
424
424
|
socketManager_1.socketManager.emitInstanceLog(systemId, instanceId, logs[0]);
|
425
|
-
socketManager_1.socketManager.emitInstanceEvent(systemId, blockInstance.id, socketManager_1.EVENT_INSTANCE_EXITED, {
|
426
|
-
error: `Failed to start instance: ${e.message}`,
|
427
|
-
status: socketManager_1.EVENT_INSTANCE_EXITED,
|
428
|
-
instanceId: blockInstance.id,
|
429
|
-
});
|
430
425
|
return out;
|
431
426
|
}
|
432
427
|
}, {
|
@@ -486,6 +481,7 @@ class InstanceManager {
|
|
486
481
|
try {
|
487
482
|
const plan = await assetManager_1.assetManager.getAsset(instance.systemId, true, false);
|
488
483
|
if (!plan) {
|
484
|
+
console.log('Plan not found - reset to stop', instance.ref, instance.systemId);
|
489
485
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
490
486
|
changed = true;
|
491
487
|
return;
|
@@ -493,12 +489,14 @@ class InstanceManager {
|
|
493
489
|
const planData = plan.data;
|
494
490
|
const planInstance = planData?.spec?.blocks?.find((b) => b.id === instance.instanceId);
|
495
491
|
if (!planInstance || !planInstance?.block?.ref) {
|
492
|
+
console.log('Plan instance not found - reset to stop', instance.ref, instance.systemId);
|
496
493
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
497
494
|
changed = true;
|
498
495
|
return;
|
499
496
|
}
|
500
497
|
const blockDef = await assetManager_1.assetManager.getAsset(instance.ref, true, false);
|
501
498
|
if (!blockDef) {
|
499
|
+
console.log('Block definition not found - reset to stop', instance.ref, instance.systemId);
|
502
500
|
instance.desiredStatus = types_1.DesiredInstanceStatus.STOP;
|
503
501
|
changed = true;
|
504
502
|
return;
|
@@ -676,6 +674,3 @@ class InstanceManager {
|
|
676
674
|
}
|
677
675
|
exports.InstanceManager = InstanceManager;
|
678
676
|
exports.instanceManager = new InstanceManager();
|
679
|
-
process.on('exit', async () => {
|
680
|
-
await exports.instanceManager.stopAll();
|
681
|
-
});
|
@@ -19,10 +19,10 @@ export declare class SocketManager {
|
|
19
19
|
emitInstanceLog(systemId: string, instanceId: string, payload: LogEntry): void;
|
20
20
|
emitSystemLog(systemId: string, payload: LogEntry): void;
|
21
21
|
emitGlobalLog(payload: LogEntry): void;
|
22
|
-
emitInstanceEvent(systemId: string, instanceId: string, type: string, payload: any): void;
|
23
22
|
private _bindIO;
|
24
23
|
private _handleSocketCreated;
|
25
24
|
private _bindSocket;
|
26
|
-
private
|
25
|
+
private handleJoinRoom;
|
26
|
+
private handleLeaveRoom;
|
27
27
|
}
|
28
28
|
export declare const socketManager: SocketManager;
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.socketManager = exports.SocketManager = exports.EVENT_LOG = exports.EVENT_SYSTEM_LOG = exports.EVENT_INSTANCE_LOG = exports.EVENT_INSTANCE_EXITED = exports.EVENT_INSTANCE_CREATED = exports.EVENT_STATUS_CHANGED = void 0;
|
7
7
|
const lodash_1 = __importDefault(require("lodash"));
|
8
8
|
const utils_1 = require("./utils/utils");
|
9
|
+
const containerManager_1 = require("./containerManager");
|
9
10
|
exports.EVENT_STATUS_CHANGED = 'status-changed';
|
10
11
|
exports.EVENT_INSTANCE_CREATED = 'instance-created';
|
11
12
|
exports.EVENT_INSTANCE_EXITED = 'instance-exited';
|
@@ -48,30 +49,28 @@ class SocketManager {
|
|
48
49
|
emitSystemEvent(systemId, type, payload) {
|
49
50
|
systemId = (0, utils_1.normalizeKapetaUri)(systemId);
|
50
51
|
try {
|
51
|
-
|
52
|
+
const contextId = `system-events/${encodeURIComponent(systemId)}`;
|
53
|
+
this.emit(contextId, type, payload);
|
52
54
|
}
|
53
55
|
catch (e) {
|
54
56
|
console.warn('Failed to emit instance event: %s', e.message);
|
55
57
|
}
|
56
58
|
}
|
57
59
|
emitInstanceLog(systemId, instanceId, payload) {
|
58
|
-
this.emitInstanceEvent(systemId, instanceId, exports.EVENT_INSTANCE_LOG, payload);
|
59
|
-
}
|
60
|
-
emitSystemLog(systemId, payload) {
|
61
|
-
this.emitSystemEvent(systemId, exports.EVENT_SYSTEM_LOG, payload);
|
62
|
-
}
|
63
|
-
emitGlobalLog(payload) {
|
64
|
-
this.emitGlobal(exports.EVENT_LOG, payload);
|
65
|
-
}
|
66
|
-
emitInstanceEvent(systemId, instanceId, type, payload) {
|
67
60
|
systemId = (0, utils_1.normalizeKapetaUri)(systemId);
|
68
61
|
try {
|
69
|
-
|
62
|
+
this.emit(`instance-logs/${encodeURIComponent(systemId)}/${encodeURIComponent(instanceId)}`, exports.EVENT_INSTANCE_LOG, payload);
|
70
63
|
}
|
71
64
|
catch (e) {
|
72
65
|
console.warn('Failed to emit instance event: %s', e.message);
|
73
66
|
}
|
74
67
|
}
|
68
|
+
emitSystemLog(systemId, payload) {
|
69
|
+
this.emitSystemEvent(systemId, exports.EVENT_SYSTEM_LOG, payload);
|
70
|
+
}
|
71
|
+
emitGlobalLog(payload) {
|
72
|
+
this.emitGlobal(exports.EVENT_LOG, payload);
|
73
|
+
}
|
75
74
|
_bindIO() {
|
76
75
|
this.io.on('connection', (socket) => this._handleSocketCreated(socket));
|
77
76
|
}
|
@@ -80,16 +79,42 @@ class SocketManager {
|
|
80
79
|
this._sockets.push(socket);
|
81
80
|
}
|
82
81
|
_bindSocket(socket) {
|
83
|
-
socket.on('disconnect', () =>
|
82
|
+
socket.on('disconnect', () => {
|
83
|
+
lodash_1.default.pull(this._sockets, socket);
|
84
|
+
socket.rooms.forEach((roomId) => {
|
85
|
+
this.handleLeaveRoom(roomId);
|
86
|
+
});
|
87
|
+
});
|
84
88
|
socket.on('join', (id) => {
|
85
89
|
socket.join(id);
|
90
|
+
this.handleJoinRoom(id);
|
86
91
|
});
|
87
92
|
socket.on('leave', (id) => {
|
88
93
|
socket.leave(id);
|
94
|
+
this.handleLeaveRoom(id);
|
89
95
|
});
|
90
96
|
}
|
91
|
-
|
92
|
-
|
97
|
+
handleJoinRoom(id) {
|
98
|
+
if (id.startsWith('instance-logs/')) {
|
99
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
100
|
+
systemId = decodeURIComponent(systemId);
|
101
|
+
instanceId = decodeURIComponent(instanceId);
|
102
|
+
console.log('Start listening for logs', systemId, instanceId);
|
103
|
+
containerManager_1.containerManager
|
104
|
+
.ensureLogListening(systemId, instanceId, (log) => {
|
105
|
+
this.emitInstanceLog(systemId, instanceId, log);
|
106
|
+
})
|
107
|
+
.catch((e) => { });
|
108
|
+
}
|
109
|
+
}
|
110
|
+
handleLeaveRoom(id) {
|
111
|
+
if (id.startsWith('instance-logs/')) {
|
112
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
113
|
+
systemId = decodeURIComponent(systemId);
|
114
|
+
instanceId = decodeURIComponent(instanceId);
|
115
|
+
console.log('Stop listening for logs', systemId, instanceId);
|
116
|
+
containerManager_1.containerManager.stopLogListening(systemId, instanceId).catch((e) => { });
|
117
|
+
}
|
93
118
|
}
|
94
119
|
}
|
95
120
|
exports.SocketManager = SocketManager;
|
package/package.json
CHANGED
package/src/containerManager.ts
CHANGED
@@ -13,6 +13,7 @@ import { getBlockInstanceContainerName } from './utils/utils';
|
|
13
13
|
import { InstanceInfo, LogEntry, LogSource } from './types';
|
14
14
|
import { KapetaAPI } from '@kapeta/nodejs-api-client';
|
15
15
|
import { taskManager, Task } from './taskManager';
|
16
|
+
import { EventEmitter } from 'node:events';
|
16
17
|
|
17
18
|
type StringMap = { [key: string]: string };
|
18
19
|
|
@@ -78,6 +79,7 @@ class ContainerManager {
|
|
78
79
|
private _mountDir: string;
|
79
80
|
private _version: string;
|
80
81
|
private _lastDockerAccessCheck: number = 0;
|
82
|
+
private logStreams: { [p: string]: { stream?: ClosableLogStream; timer?: NodeJS.Timeout } } = {};
|
81
83
|
|
82
84
|
constructor() {
|
83
85
|
this._docker = null;
|
@@ -607,6 +609,193 @@ class ContainerManager {
|
|
607
609
|
|
608
610
|
return containerInfo.getLogs();
|
609
611
|
}
|
612
|
+
|
613
|
+
async stopLogListening(systemId: string, instanceId: string) {
|
614
|
+
const containerName = getBlockInstanceContainerName(systemId, instanceId);
|
615
|
+
if (this.logStreams[containerName]) {
|
616
|
+
if (this.logStreams[containerName]?.timer) {
|
617
|
+
clearTimeout(this.logStreams[containerName].timer);
|
618
|
+
}
|
619
|
+
|
620
|
+
console.log('Stopped listening for logs on container: %s', containerName);
|
621
|
+
try {
|
622
|
+
const stream = this.logStreams[containerName].stream;
|
623
|
+
if (stream) {
|
624
|
+
await stream.close();
|
625
|
+
}
|
626
|
+
} catch (err) {
|
627
|
+
// Ignore
|
628
|
+
}
|
629
|
+
delete this.logStreams[containerName];
|
630
|
+
}
|
631
|
+
}
|
632
|
+
|
633
|
+
async ensureLogListening(systemId: string, instanceId: string, handler: (log: LogEntry) => void) {
|
634
|
+
const containerName = getBlockInstanceContainerName(systemId, instanceId);
|
635
|
+
try {
|
636
|
+
if (this.logStreams[containerName]?.stream) {
|
637
|
+
// Already listening - will shut itself down
|
638
|
+
return;
|
639
|
+
}
|
640
|
+
|
641
|
+
if (this.logStreams[containerName]?.timer) {
|
642
|
+
clearTimeout(this.logStreams[containerName].timer);
|
643
|
+
}
|
644
|
+
|
645
|
+
const tryLater = () => {
|
646
|
+
this.logStreams[containerName] = {
|
647
|
+
timer: setTimeout(() => {
|
648
|
+
// Keep trying until user decides to not listen anymore
|
649
|
+
this.ensureLogListening(systemId, instanceId, handler);
|
650
|
+
}, 5000),
|
651
|
+
};
|
652
|
+
};
|
653
|
+
|
654
|
+
const containerInfo = await this.getContainerByName(containerName);
|
655
|
+
if (!containerInfo || !(await containerInfo.isRunning())) {
|
656
|
+
// Container not currently running - try again in 5 seconds
|
657
|
+
tryLater();
|
658
|
+
return;
|
659
|
+
}
|
660
|
+
|
661
|
+
const stream = await containerInfo.getLogStream();
|
662
|
+
stream.onLog((log) => {
|
663
|
+
try {
|
664
|
+
handler(log);
|
665
|
+
} catch (err) {
|
666
|
+
console.warn('Error handling log', err);
|
667
|
+
}
|
668
|
+
});
|
669
|
+
stream.onEnd(() => {
|
670
|
+
// We get here if the container is stopped
|
671
|
+
delete this.logStreams[containerName];
|
672
|
+
tryLater();
|
673
|
+
});
|
674
|
+
stream.onError((err) => {
|
675
|
+
// We get here if the container crashes
|
676
|
+
delete this.logStreams[containerName];
|
677
|
+
tryLater();
|
678
|
+
});
|
679
|
+
|
680
|
+
this.logStreams[containerName] = {
|
681
|
+
stream,
|
682
|
+
};
|
683
|
+
} catch (err) {
|
684
|
+
// Ignore
|
685
|
+
}
|
686
|
+
}
|
687
|
+
}
|
688
|
+
|
689
|
+
function readLogBuffer(logBuffer: Buffer) {
|
690
|
+
const out: LogEntry[] = [];
|
691
|
+
let offset = 0;
|
692
|
+
while (offset < logBuffer.length) {
|
693
|
+
try {
|
694
|
+
// Read the docker log format - explained here:
|
695
|
+
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
696
|
+
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
697
|
+
|
698
|
+
// First byte is stream type
|
699
|
+
const streamTypeInt = logBuffer.readInt8(offset);
|
700
|
+
const streamType: LogSource = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
701
|
+
if (streamTypeInt !== 1 && streamTypeInt !== 2) {
|
702
|
+
console.error('Unknown stream type: %s', streamTypeInt, out[out.length - 1]);
|
703
|
+
break;
|
704
|
+
}
|
705
|
+
|
706
|
+
// Bytes 4-8 is frame size
|
707
|
+
const messageLength = logBuffer.readInt32BE(offset + 4);
|
708
|
+
|
709
|
+
// After that is the message - with the message length
|
710
|
+
const dataWithoutStreamType = logBuffer.subarray(offset + 8, offset + 8 + messageLength);
|
711
|
+
const raw = dataWithoutStreamType.toString();
|
712
|
+
|
713
|
+
// Split the message into date and message
|
714
|
+
const firstSpaceIx = raw.indexOf(' ');
|
715
|
+
const dateString = raw.substring(0, firstSpaceIx);
|
716
|
+
const line = raw.substring(firstSpaceIx + 1);
|
717
|
+
offset = offset + messageLength + 8;
|
718
|
+
if (!dateString) {
|
719
|
+
break;
|
720
|
+
}
|
721
|
+
out.push({
|
722
|
+
time: new Date(dateString).getTime(),
|
723
|
+
message: line,
|
724
|
+
level: 'INFO',
|
725
|
+
source: streamType,
|
726
|
+
});
|
727
|
+
} catch (err) {
|
728
|
+
console.error('Error parsing log entry', err);
|
729
|
+
offset = logBuffer.length;
|
730
|
+
break;
|
731
|
+
}
|
732
|
+
}
|
733
|
+
return out;
|
734
|
+
}
|
735
|
+
|
736
|
+
class ClosableLogStream {
|
737
|
+
private readonly stream: FSExtra.ReadStream;
|
738
|
+
|
739
|
+
private readonly eventEmitter: EventEmitter;
|
740
|
+
|
741
|
+
constructor(stream: FSExtra.ReadStream) {
|
742
|
+
this.stream = stream;
|
743
|
+
this.eventEmitter = new EventEmitter();
|
744
|
+
stream.on('data', (data) => {
|
745
|
+
const logs = readLogBuffer(data as Buffer);
|
746
|
+
logs.forEach((log) => {
|
747
|
+
this.eventEmitter.emit('log', log);
|
748
|
+
});
|
749
|
+
});
|
750
|
+
|
751
|
+
stream.on('end', () => {
|
752
|
+
this.eventEmitter.emit('end');
|
753
|
+
});
|
754
|
+
|
755
|
+
stream.on('error', (error) => {
|
756
|
+
this.eventEmitter.emit('error', error);
|
757
|
+
});
|
758
|
+
|
759
|
+
stream.on('close', () => {
|
760
|
+
this.eventEmitter.emit('end');
|
761
|
+
});
|
762
|
+
}
|
763
|
+
|
764
|
+
onLog(listener: (log: LogEntry) => void) {
|
765
|
+
this.eventEmitter.on('log', listener);
|
766
|
+
return () => {
|
767
|
+
this.eventEmitter.removeListener('log', listener);
|
768
|
+
};
|
769
|
+
}
|
770
|
+
|
771
|
+
onEnd(listener: () => void) {
|
772
|
+
this.eventEmitter.on('end', listener);
|
773
|
+
return () => {
|
774
|
+
this.eventEmitter.removeListener('end', listener);
|
775
|
+
};
|
776
|
+
}
|
777
|
+
|
778
|
+
onError(listener: (error: Error) => void) {
|
779
|
+
this.eventEmitter.on('error', listener);
|
780
|
+
return () => {
|
781
|
+
this.eventEmitter.removeListener('error', listener);
|
782
|
+
};
|
783
|
+
}
|
784
|
+
|
785
|
+
close() {
|
786
|
+
return new Promise<void>((resolve, reject) => {
|
787
|
+
try {
|
788
|
+
this.stream.close((err) => {
|
789
|
+
if (err) {
|
790
|
+
console.warn('Error closing log stream', err);
|
791
|
+
}
|
792
|
+
resolve();
|
793
|
+
});
|
794
|
+
} catch (err) {
|
795
|
+
// Ignore
|
796
|
+
}
|
797
|
+
});
|
798
|
+
}
|
610
799
|
}
|
611
800
|
|
612
801
|
export class ContainerInfo {
|
@@ -718,57 +907,38 @@ export class ContainerInfo {
|
|
718
907
|
return ports;
|
719
908
|
}
|
720
909
|
|
910
|
+
async getLogStream() {
|
911
|
+
try {
|
912
|
+
const logStream = (await this.native.logs({
|
913
|
+
stdout: true,
|
914
|
+
stderr: true,
|
915
|
+
follow: true,
|
916
|
+
tail: 0,
|
917
|
+
timestamps: true,
|
918
|
+
})) as ReadStream;
|
919
|
+
|
920
|
+
return new ClosableLogStream(logStream);
|
921
|
+
} catch (err) {
|
922
|
+
console.log('Error getting log stream', err);
|
923
|
+
throw err;
|
924
|
+
}
|
925
|
+
}
|
926
|
+
|
721
927
|
async getLogs(): Promise<LogEntry[]> {
|
722
928
|
const logStream = (await this.native.logs({
|
723
929
|
stdout: true,
|
724
930
|
stderr: true,
|
725
931
|
follow: false,
|
726
|
-
tail: 100,
|
727
932
|
timestamps: true,
|
728
933
|
})) as ReadStream;
|
729
934
|
|
730
|
-
const
|
935
|
+
const chunks: Buffer[] = [];
|
731
936
|
await promisifyStream(logStream, (data) => {
|
732
|
-
|
733
|
-
let offset = 0;
|
734
|
-
while (offset < buf.length) {
|
735
|
-
try {
|
736
|
-
// Read the docker log format - explained here:
|
737
|
-
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
738
|
-
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
739
|
-
|
740
|
-
// First byte is stream type
|
741
|
-
const streamTypeInt = buf.readInt8(offset);
|
742
|
-
const streamType: LogSource = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
743
|
-
|
744
|
-
// Bytes 4-8 is frame size
|
745
|
-
const messageLength = buf.readInt32BE(offset + 4);
|
746
|
-
|
747
|
-
// After that is the message - with the message length
|
748
|
-
const dataWithoutStreamType = buf.subarray(offset + 8, offset + 8 + messageLength);
|
749
|
-
const raw = dataWithoutStreamType.toString();
|
750
|
-
|
751
|
-
// Split the message into date and message
|
752
|
-
const firstSpaceIx = raw.indexOf(' ');
|
753
|
-
const dateString = raw.substring(0, firstSpaceIx);
|
754
|
-
const line = raw.substring(firstSpaceIx + 1);
|
755
|
-
offset = offset + messageLength + 8;
|
756
|
-
if (!dateString) {
|
757
|
-
continue;
|
758
|
-
}
|
759
|
-
out.push({
|
760
|
-
time: new Date(dateString).getTime(),
|
761
|
-
message: line,
|
762
|
-
level: 'INFO',
|
763
|
-
source: streamType,
|
764
|
-
});
|
765
|
-
} catch (err) {
|
766
|
-
console.error('Error parsing log entry', err);
|
767
|
-
offset = buf.length;
|
768
|
-
}
|
769
|
-
}
|
937
|
+
chunks.push(data as Buffer);
|
770
938
|
});
|
771
939
|
|
940
|
+
const out = readLogBuffer(Buffer.concat(chunks));
|
941
|
+
|
772
942
|
if (out.length === 0) {
|
773
943
|
out.push({
|
774
944
|
time: Date.now(),
|
package/src/instanceManager.ts
CHANGED
@@ -529,12 +529,6 @@ export class InstanceManager {
|
|
529
529
|
|
530
530
|
socketManager.emitInstanceLog(systemId, instanceId, logs[0]);
|
531
531
|
|
532
|
-
socketManager.emitInstanceEvent(systemId, blockInstance.id, EVENT_INSTANCE_EXITED, {
|
533
|
-
error: `Failed to start instance: ${e.message}`,
|
534
|
-
status: EVENT_INSTANCE_EXITED,
|
535
|
-
instanceId: blockInstance.id,
|
536
|
-
});
|
537
|
-
|
538
532
|
return out;
|
539
533
|
}
|
540
534
|
},
|
@@ -606,6 +600,7 @@ export class InstanceManager {
|
|
606
600
|
try {
|
607
601
|
const plan = await assetManager.getAsset(instance.systemId, true, false);
|
608
602
|
if (!plan) {
|
603
|
+
console.log('Plan not found - reset to stop', instance.ref, instance.systemId);
|
609
604
|
instance.desiredStatus = DesiredInstanceStatus.STOP;
|
610
605
|
changed = true;
|
611
606
|
return;
|
@@ -614,6 +609,7 @@ export class InstanceManager {
|
|
614
609
|
const planData = plan.data as Plan;
|
615
610
|
const planInstance = planData?.spec?.blocks?.find((b) => b.id === instance.instanceId);
|
616
611
|
if (!planInstance || !planInstance?.block?.ref) {
|
612
|
+
console.log('Plan instance not found - reset to stop', instance.ref, instance.systemId);
|
617
613
|
instance.desiredStatus = DesiredInstanceStatus.STOP;
|
618
614
|
changed = true;
|
619
615
|
return;
|
@@ -621,6 +617,7 @@ export class InstanceManager {
|
|
621
617
|
|
622
618
|
const blockDef = await assetManager.getAsset(instance.ref, true, false);
|
623
619
|
if (!blockDef) {
|
620
|
+
console.log('Block definition not found - reset to stop', instance.ref, instance.systemId);
|
624
621
|
instance.desiredStatus = DesiredInstanceStatus.STOP;
|
625
622
|
changed = true;
|
626
623
|
return;
|
@@ -836,7 +833,3 @@ export class InstanceManager {
|
|
836
833
|
}
|
837
834
|
|
838
835
|
export const instanceManager = new InstanceManager();
|
839
|
-
|
840
|
-
process.on('exit', async () => {
|
841
|
-
await instanceManager.stopAll();
|
842
|
-
});
|
package/src/socketManager.ts
CHANGED
@@ -2,6 +2,7 @@ import _ from 'lodash';
|
|
2
2
|
import { Socket, Server } from 'socket.io';
|
3
3
|
import { normalizeKapetaUri } from './utils/utils';
|
4
4
|
import { LogEntry } from './types';
|
5
|
+
import { containerManager } from './containerManager';
|
5
6
|
export const EVENT_STATUS_CHANGED = 'status-changed';
|
6
7
|
export const EVENT_INSTANCE_CREATED = 'instance-created';
|
7
8
|
export const EVENT_INSTANCE_EXITED = 'instance-exited';
|
@@ -53,14 +54,24 @@ export class SocketManager {
|
|
53
54
|
emitSystemEvent(systemId: string, type: string, payload: any) {
|
54
55
|
systemId = normalizeKapetaUri(systemId);
|
55
56
|
try {
|
56
|
-
|
57
|
+
const contextId = `system-events/${encodeURIComponent(systemId)}`;
|
58
|
+
this.emit(contextId, type, payload);
|
57
59
|
} catch (e: any) {
|
58
60
|
console.warn('Failed to emit instance event: %s', e.message);
|
59
61
|
}
|
60
62
|
}
|
61
63
|
|
62
64
|
emitInstanceLog(systemId: string, instanceId: string, payload: LogEntry) {
|
63
|
-
|
65
|
+
systemId = normalizeKapetaUri(systemId);
|
66
|
+
try {
|
67
|
+
this.emit(
|
68
|
+
`instance-logs/${encodeURIComponent(systemId)}/${encodeURIComponent(instanceId)}`,
|
69
|
+
EVENT_INSTANCE_LOG,
|
70
|
+
payload
|
71
|
+
);
|
72
|
+
} catch (e: any) {
|
73
|
+
console.warn('Failed to emit instance event: %s', e.message);
|
74
|
+
}
|
64
75
|
}
|
65
76
|
|
66
77
|
emitSystemLog(systemId: string, payload: LogEntry) {
|
@@ -71,15 +82,6 @@ export class SocketManager {
|
|
71
82
|
this.emitGlobal(EVENT_LOG, payload);
|
72
83
|
}
|
73
84
|
|
74
|
-
emitInstanceEvent(systemId: string, instanceId: string, type: string, payload: any) {
|
75
|
-
systemId = normalizeKapetaUri(systemId);
|
76
|
-
try {
|
77
|
-
socketManager.emit(`${systemId}/instances/${instanceId}`, type, payload);
|
78
|
-
} catch (e: any) {
|
79
|
-
console.warn('Failed to emit instance event: %s', e.message);
|
80
|
-
}
|
81
|
-
}
|
82
|
-
|
83
85
|
private _bindIO() {
|
84
86
|
this.io.on('connection', (socket) => this._handleSocketCreated(socket));
|
85
87
|
}
|
@@ -90,17 +92,42 @@ export class SocketManager {
|
|
90
92
|
}
|
91
93
|
|
92
94
|
private _bindSocket(socket: Socket) {
|
93
|
-
socket.on('disconnect', () =>
|
95
|
+
socket.on('disconnect', () => {
|
96
|
+
_.pull(this._sockets, socket);
|
97
|
+
socket.rooms.forEach((roomId) => {
|
98
|
+
this.handleLeaveRoom(roomId);
|
99
|
+
});
|
100
|
+
});
|
94
101
|
socket.on('join', (id) => {
|
95
102
|
socket.join(id);
|
103
|
+
this.handleJoinRoom(id);
|
96
104
|
});
|
97
105
|
socket.on('leave', (id) => {
|
98
106
|
socket.leave(id);
|
107
|
+
this.handleLeaveRoom(id);
|
99
108
|
});
|
100
109
|
}
|
101
|
-
|
102
|
-
|
103
|
-
|
110
|
+
private handleJoinRoom(id: string) {
|
111
|
+
if (id.startsWith('instance-logs/')) {
|
112
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
113
|
+
systemId = decodeURIComponent(systemId);
|
114
|
+
instanceId = decodeURIComponent(instanceId);
|
115
|
+
console.log('Start listening for logs', systemId, instanceId);
|
116
|
+
containerManager
|
117
|
+
.ensureLogListening(systemId, instanceId, (log) => {
|
118
|
+
this.emitInstanceLog(systemId, instanceId, log);
|
119
|
+
})
|
120
|
+
.catch((e) => {});
|
121
|
+
}
|
122
|
+
}
|
123
|
+
private handleLeaveRoom(id: string) {
|
124
|
+
if (id.startsWith('instance-logs/')) {
|
125
|
+
let [, systemId, instanceId] = id.split(/\//g);
|
126
|
+
systemId = decodeURIComponent(systemId);
|
127
|
+
instanceId = decodeURIComponent(instanceId);
|
128
|
+
console.log('Stop listening for logs', systemId, instanceId);
|
129
|
+
containerManager.stopLogListening(systemId, instanceId).catch((e) => {});
|
130
|
+
}
|
104
131
|
}
|
105
132
|
}
|
106
133
|
export const socketManager = new SocketManager();
|