@kapeta/local-cluster-service 0.18.0 → 0.19.1
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 +14 -0
- package/dist/cjs/src/containerManager.d.ts +15 -0
- package/dist/cjs/src/containerManager.js +189 -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 +189 -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 +208 -40
- package/src/instanceManager.ts +3 -10
- package/src/socketManager.ts +42 -15
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,191 @@ 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
|
+
try {
|
620
|
+
const stream = this.logStreams[containerName].stream;
|
621
|
+
if (stream) {
|
622
|
+
await stream.close();
|
623
|
+
}
|
624
|
+
} catch (err) {
|
625
|
+
// Ignore
|
626
|
+
}
|
627
|
+
delete this.logStreams[containerName];
|
628
|
+
}
|
629
|
+
}
|
630
|
+
|
631
|
+
async ensureLogListening(systemId: string, instanceId: string, handler: (log: LogEntry) => void) {
|
632
|
+
const containerName = getBlockInstanceContainerName(systemId, instanceId);
|
633
|
+
try {
|
634
|
+
if (this.logStreams[containerName]?.stream) {
|
635
|
+
// Already listening - will shut itself down
|
636
|
+
return;
|
637
|
+
}
|
638
|
+
|
639
|
+
if (this.logStreams[containerName]?.timer) {
|
640
|
+
clearTimeout(this.logStreams[containerName].timer);
|
641
|
+
}
|
642
|
+
|
643
|
+
const tryLater = () => {
|
644
|
+
this.logStreams[containerName] = {
|
645
|
+
timer: setTimeout(() => {
|
646
|
+
// Keep trying until user decides to not listen anymore
|
647
|
+
this.ensureLogListening(systemId, instanceId, handler);
|
648
|
+
}, 5000),
|
649
|
+
};
|
650
|
+
};
|
651
|
+
|
652
|
+
const containerInfo = await this.getContainerByName(containerName);
|
653
|
+
if (!containerInfo || !(await containerInfo.isRunning())) {
|
654
|
+
// Container not currently running - try again in 5 seconds
|
655
|
+
tryLater();
|
656
|
+
return;
|
657
|
+
}
|
658
|
+
|
659
|
+
const stream = await containerInfo.getLogStream();
|
660
|
+
stream.onLog((log) => {
|
661
|
+
try {
|
662
|
+
handler(log);
|
663
|
+
} catch (err) {
|
664
|
+
console.warn('Error handling log', err);
|
665
|
+
}
|
666
|
+
});
|
667
|
+
stream.onEnd(() => {
|
668
|
+
// We get here if the container is stopped
|
669
|
+
delete this.logStreams[containerName];
|
670
|
+
tryLater();
|
671
|
+
});
|
672
|
+
stream.onError((err) => {
|
673
|
+
// We get here if the container crashes
|
674
|
+
delete this.logStreams[containerName];
|
675
|
+
tryLater();
|
676
|
+
});
|
677
|
+
|
678
|
+
this.logStreams[containerName] = {
|
679
|
+
stream,
|
680
|
+
};
|
681
|
+
} catch (err) {
|
682
|
+
// Ignore
|
683
|
+
}
|
684
|
+
}
|
685
|
+
}
|
686
|
+
|
687
|
+
function readLogBuffer(logBuffer: Buffer) {
|
688
|
+
const out: LogEntry[] = [];
|
689
|
+
let offset = 0;
|
690
|
+
while (offset < logBuffer.length) {
|
691
|
+
try {
|
692
|
+
// Read the docker log format - explained here:
|
693
|
+
// https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach
|
694
|
+
// or here : https://ahmet.im/blog/docker-logs-api-binary-format-explained/
|
695
|
+
|
696
|
+
// First byte is stream type
|
697
|
+
const streamTypeInt = logBuffer.readInt8(offset);
|
698
|
+
const streamType: LogSource = streamTypeInt === 1 ? 'stdout' : 'stderr';
|
699
|
+
if (streamTypeInt !== 1 && streamTypeInt !== 2) {
|
700
|
+
console.error('Unknown stream type: %s', streamTypeInt, out[out.length - 1]);
|
701
|
+
break;
|
702
|
+
}
|
703
|
+
|
704
|
+
// Bytes 4-8 is frame size
|
705
|
+
const messageLength = logBuffer.readInt32BE(offset + 4);
|
706
|
+
|
707
|
+
// After that is the message - with the message length
|
708
|
+
const dataWithoutStreamType = logBuffer.subarray(offset + 8, offset + 8 + messageLength);
|
709
|
+
const raw = dataWithoutStreamType.toString();
|
710
|
+
|
711
|
+
// Split the message into date and message
|
712
|
+
const firstSpaceIx = raw.indexOf(' ');
|
713
|
+
const dateString = raw.substring(0, firstSpaceIx);
|
714
|
+
const line = raw.substring(firstSpaceIx + 1);
|
715
|
+
offset = offset + messageLength + 8;
|
716
|
+
if (!dateString) {
|
717
|
+
break;
|
718
|
+
}
|
719
|
+
out.push({
|
720
|
+
time: new Date(dateString).getTime(),
|
721
|
+
message: line,
|
722
|
+
level: 'INFO',
|
723
|
+
source: streamType,
|
724
|
+
});
|
725
|
+
} catch (err) {
|
726
|
+
console.error('Error parsing log entry', err);
|
727
|
+
offset = logBuffer.length;
|
728
|
+
break;
|
729
|
+
}
|
730
|
+
}
|
731
|
+
return out;
|
732
|
+
}
|
733
|
+
|
734
|
+
class ClosableLogStream {
|
735
|
+
private readonly stream: FSExtra.ReadStream;
|
736
|
+
|
737
|
+
private readonly eventEmitter: EventEmitter;
|
738
|
+
|
739
|
+
constructor(stream: FSExtra.ReadStream) {
|
740
|
+
this.stream = stream;
|
741
|
+
this.eventEmitter = new EventEmitter();
|
742
|
+
stream.on('data', (data) => {
|
743
|
+
const logs = readLogBuffer(data as Buffer);
|
744
|
+
logs.forEach((log) => {
|
745
|
+
this.eventEmitter.emit('log', log);
|
746
|
+
});
|
747
|
+
});
|
748
|
+
|
749
|
+
stream.on('end', () => {
|
750
|
+
this.eventEmitter.emit('end');
|
751
|
+
});
|
752
|
+
|
753
|
+
stream.on('error', (error) => {
|
754
|
+
this.eventEmitter.emit('error', error);
|
755
|
+
});
|
756
|
+
|
757
|
+
stream.on('close', () => {
|
758
|
+
this.eventEmitter.emit('end');
|
759
|
+
});
|
760
|
+
}
|
761
|
+
|
762
|
+
onLog(listener: (log: LogEntry) => void) {
|
763
|
+
this.eventEmitter.on('log', listener);
|
764
|
+
return () => {
|
765
|
+
this.eventEmitter.removeListener('log', listener);
|
766
|
+
};
|
767
|
+
}
|
768
|
+
|
769
|
+
onEnd(listener: () => void) {
|
770
|
+
this.eventEmitter.on('end', listener);
|
771
|
+
return () => {
|
772
|
+
this.eventEmitter.removeListener('end', listener);
|
773
|
+
};
|
774
|
+
}
|
775
|
+
|
776
|
+
onError(listener: (error: Error) => void) {
|
777
|
+
this.eventEmitter.on('error', listener);
|
778
|
+
return () => {
|
779
|
+
this.eventEmitter.removeListener('error', listener);
|
780
|
+
};
|
781
|
+
}
|
782
|
+
|
783
|
+
close() {
|
784
|
+
return new Promise<void>((resolve, reject) => {
|
785
|
+
try {
|
786
|
+
this.stream.close((err) => {
|
787
|
+
if (err) {
|
788
|
+
console.warn('Error closing log stream', err);
|
789
|
+
}
|
790
|
+
resolve();
|
791
|
+
});
|
792
|
+
} catch (err) {
|
793
|
+
// Ignore
|
794
|
+
}
|
795
|
+
});
|
796
|
+
}
|
610
797
|
}
|
611
798
|
|
612
799
|
export class ContainerInfo {
|
@@ -718,57 +905,38 @@ export class ContainerInfo {
|
|
718
905
|
return ports;
|
719
906
|
}
|
720
907
|
|
908
|
+
async getLogStream() {
|
909
|
+
try {
|
910
|
+
const logStream = (await this.native.logs({
|
911
|
+
stdout: true,
|
912
|
+
stderr: true,
|
913
|
+
follow: true,
|
914
|
+
tail: 0,
|
915
|
+
timestamps: true,
|
916
|
+
})) as ReadStream;
|
917
|
+
|
918
|
+
return new ClosableLogStream(logStream);
|
919
|
+
} catch (err) {
|
920
|
+
console.log('Error getting log stream', err);
|
921
|
+
throw err;
|
922
|
+
}
|
923
|
+
}
|
924
|
+
|
721
925
|
async getLogs(): Promise<LogEntry[]> {
|
722
926
|
const logStream = (await this.native.logs({
|
723
927
|
stdout: true,
|
724
928
|
stderr: true,
|
725
929
|
follow: false,
|
726
|
-
tail: 100,
|
727
930
|
timestamps: true,
|
728
931
|
})) as ReadStream;
|
729
932
|
|
730
|
-
const
|
933
|
+
const chunks: Buffer[] = [];
|
731
934
|
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
|
-
}
|
935
|
+
chunks.push(data as Buffer);
|
770
936
|
});
|
771
937
|
|
938
|
+
const out = readLogBuffer(Buffer.concat(chunks));
|
939
|
+
|
772
940
|
if (out.length === 0) {
|
773
941
|
out.push({
|
774
942
|
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();
|