@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.
@@ -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 out = [] as LogEntry[];
933
+ const chunks: Buffer[] = [];
731
934
  await promisifyStream(logStream, (data) => {
732
- const buf = data as Buffer;
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(),
@@ -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
- });
@@ -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
- socketManager.emit(`${systemId}/instances`, type, payload);
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
- this.emitInstanceEvent(systemId, instanceId, EVENT_INSTANCE_LOG, payload);
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', () => this._handleSocketDestroyed(socket));
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
- private _handleSocketDestroyed(socket: Socket) {
103
- _.pull(this._sockets, socket);
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();