@milaboratories/pl-deployments 1.1.4 → 1.1.6

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.
@@ -65,7 +65,7 @@ export async function initContainer(name: string): Promise<StartedTestContainer>
65
65
  .withReuse()
66
66
  .withName(`pl-ssh-test-${name}`)
67
67
  .start()
68
- .catch((err) => console.log('No worries, creating a new container'));
68
+ .catch((err: any) => console.log('No worries, creating a new container'));
69
69
 
70
70
  if (!fromCacheContainer) {
71
71
  generateKeys();
package/src/ssh/pl.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type * as ssh from 'ssh2';
2
2
  import { SshClient } from './ssh';
3
3
  import type { MiLogger } from '@milaboratories/ts-helpers';
4
- import { sleep, notEmpty, fileExists } from '@milaboratories/ts-helpers';
4
+ import { sleep, notEmpty } from '@milaboratories/ts-helpers';
5
5
  import type { DownloadBinaryResult } from '../common/pl_binary_download';
6
6
  import { downloadBinaryNoExtract } from '../common/pl_binary_download';
7
7
  import upath from 'upath';
@@ -19,7 +19,7 @@ export class SshPl {
19
19
  public readonly logger: MiLogger,
20
20
  public readonly sshClient: SshClient,
21
21
  private readonly username: string,
22
- ) {}
22
+ ) { }
23
23
 
24
24
  public info() {
25
25
  return {
@@ -38,6 +38,10 @@ export class SshPl {
38
38
  }
39
39
  }
40
40
 
41
+ public cleanUp() {
42
+ this.sshClient.close();
43
+ }
44
+
41
45
  public async isAlive(): Promise<boolean> {
42
46
  const arch = await this.getArch();
43
47
  const remoteHome = await this.getUserHomeDirectory();
@@ -89,6 +93,8 @@ export class SshPl {
89
93
  this.logger.info(`pl.reset: Deleting Platforma workDir ${workDir} on the server`);
90
94
  await this.sshClient.deleteFolder(plpath.workDir(workDir));
91
95
 
96
+ this.cleanUp();
97
+
92
98
  return true;
93
99
  }
94
100
 
package/src/ssh/ssh.ts CHANGED
@@ -5,7 +5,8 @@ import dns from 'dns';
5
5
  import fs from 'fs';
6
6
  import { readFile } from 'fs/promises';
7
7
  import upath from 'upath';
8
- import type { MiLogger } from '@milaboratories/ts-helpers';
8
+ import { RetryablePromise, type MiLogger } from '@milaboratories/ts-helpers';
9
+ import { randomBytes } from 'crypto';
9
10
 
10
11
  const defaultConfig: ConnectConfig = {
11
12
  keepaliveInterval: 60000,
@@ -22,6 +23,7 @@ export type SshDirContent = {
22
23
  export class SshClient {
23
24
  private config?: ConnectConfig;
24
25
  public homeDir?: string;
26
+ private forwardedServers: net.Server[] = [];
25
27
 
26
28
  constructor(
27
29
  private readonly logger: MiLogger,
@@ -45,6 +47,10 @@ export class SshClient {
45
47
  return client;
46
48
  }
47
49
 
50
+ public getForwardedServers() {
51
+ return this.forwardedServers;
52
+ }
53
+
48
54
  public getFullHostName() {
49
55
  return `${this.config?.host}:${this.config?.port}`;
50
56
  }
@@ -60,16 +66,7 @@ export class SshClient {
60
66
  */
61
67
  public async connect(config: ConnectConfig) {
62
68
  this.config = config;
63
- return new Promise((resolve, reject) => {
64
- this.client.on('ready', () => {
65
- resolve(undefined);
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- }).on('error', (err: any) => {
68
- reject(new Error(`ssh.connect: error occurred: ${err}`));
69
- }).on('timeout', () => {
70
- reject(new Error(`timeout was occurred while waiting for SSH connection.`));
71
- }).connect(config);
72
- });
69
+ return await connect(this.client, config, () => {}, () => {});
73
70
  }
74
71
 
75
72
  /**
@@ -154,62 +151,112 @@ export class SshClient {
154
151
  * @returns { server: net.Server } A promise resolving with the created server instance.
155
152
  */
156
153
  public async forwardPort(ports: { remotePort: number; localPort: number; localHost?: string }, config?: ConnectConfig): Promise<{ server: net.Server }> {
154
+ const log = `ssh.forward:${ports.localPort}:${ports.remotePort}.id_${randomBytes(1).toString('hex')}`;
157
155
  config = config ?? this.config;
158
- return new Promise((resolve, reject) => {
159
- if (!config) {
160
- reject('No config defined');
161
- return;
162
- }
163
- const conn = new Client();
164
- let server: net.Server;
165
- conn.on('ready', () => {
166
- this.logger.info(`[SSH] Connection to ${config.host}. Remote port ${ports.remotePort} will be available locally on the ${ports.localPort}`);
167
- server = net.createServer({ pauseOnConnect: true }, (localSocket) => {
168
- conn.forwardOut('127.0.0.1', 0, '127.0.0.1', ports.remotePort,
169
- (err, stream) => {
170
- if (err) {
171
- console.error('Error opening SSH channel:', err.message);
172
- localSocket.end();
173
- return;
174
- }
175
- localSocket.pipe(stream);
176
- stream.pipe(localSocket);
177
- localSocket.resume();
178
- },
179
- );
156
+
157
+ // we make this thing persistent so that if the connection
158
+ // drops (it happened in the past because of lots of errors and forwardOut opened channels),
159
+ // we'll recreate it here.
160
+ const persistentClient = new RetryablePromise((p: RetryablePromise<Client>) => {
161
+ return new Promise<Client>((resolve, reject) => {
162
+ const client = new Client();
163
+
164
+ client.on('ready', () => {
165
+ this.logger.info(`${log}.client.ready`);
166
+ resolve(client);
180
167
  });
181
- server.listen(ports.localPort, '127.0.0.1', () => {
182
- this.logger.info(`[+] Port local ${ports.localPort} available locally for remote port → :${ports.remotePort}`);
183
- resolve({ server });
168
+
169
+ client.on('error', (err) => {
170
+ this.logger.info(`${log}.client.error: ${err}`);
171
+ p.reset();
172
+ reject(err);
184
173
  });
185
174
 
186
- server.on('error', (err) => {
187
- conn.end();
188
- server.close();
189
- reject(new Error(`ssh.forwardPort: server error: ${err}`));
175
+ client.on('close', () => {
176
+ this.logger.info(`${log}.client.closed`);
177
+ p.reset();
190
178
  });
191
179
 
192
- server.on('close', () => {
193
- this.logger.info(`Server closed ${JSON.stringify(ports)}`);
194
- if (conn) {
195
- this.logger.info(`End SSH connection`);
196
- conn.end();
197
- }
180
+ client.connect(config!);
181
+ });
182
+ });
183
+
184
+ await persistentClient.ensure(); // warm up a connection
185
+
186
+ return new Promise((resolve, reject) => {
187
+ const server = net.createServer({ pauseOnConnect: true }, async (localSocket) => {
188
+ const sockLog = `${log}.sock_${randomBytes(1).toString('hex')}`;
189
+ // this.logger.info(`${sockLog}.localSocket: start connection`);
190
+ let conn: Client;
191
+ try {
192
+ conn = await persistentClient.ensure();
193
+ } catch (e: unknown) {
194
+ this.logger.info(`${sockLog}.persistentClient.catch: ${e}`);
195
+ localSocket.end();
196
+ return;
197
+ }
198
+
199
+ let stream: ClientChannel;
200
+ try {
201
+ stream = await forwardOut(this.logger, conn, '127.0.0.1', 0, '127.0.0.1', ports.remotePort);
202
+ } catch (e: unknown) {
203
+ this.logger.error(`${sockLog}.forwardOut.err: ${e}`);
204
+ localSocket.end();
205
+ return;
206
+ }
207
+
208
+ localSocket.pipe(stream);
209
+ stream.pipe(localSocket);
210
+ localSocket.resume();
211
+ // this.logger.info(`${sockLog}.forwardOut: connected`);
212
+
213
+ stream.on('error', (err: unknown) => {
214
+ this.logger.error(`${sockLog}.stream.error: ${err}`);
215
+ localSocket.end();
216
+ stream.end();
198
217
  });
218
+ stream.on('close', () => {
219
+ // this.logger.info(`${sockLog}.stream.close: closed`);
220
+ localSocket.end();
221
+ stream.end();
222
+ });
223
+ localSocket.on('close', () => {
224
+ this.logger.info(`${sockLog}.localSocket: closed`);
225
+ localSocket.end();
226
+ stream.end();
227
+ });
228
+ });
229
+
230
+ server.listen(ports.localPort, '127.0.0.1', () => {
231
+ this.logger.info(`${log}.server: started listening`);
232
+ this.forwardedServers.push(server);
233
+ resolve({ server });
199
234
  });
200
235
 
201
- conn.on('error', (err) => {
202
- this.logger.error(`[SSH] SSH connection error, ports: ${JSON.stringify(ports)}, err: ${err.message}`);
203
- server?.close();
204
- reject(`ssh.forwardPort: conn.err: ${err}`);
236
+ server.on('error', (err) => {
237
+ server.close();
238
+ reject(new Error(`${log}.server: error: ${JSON.stringify(err)}`));
205
239
  });
206
240
 
207
- conn.on('close', () => {
208
- this.logger.info(`[SSH] Connection closed, ports: ${JSON.stringify(ports)}`);
241
+ server.on('close', () => {
242
+ this.logger.info(`${log}.server: closed ${JSON.stringify(ports)}`);
243
+ this.forwardedServers = this.forwardedServers.filter((s) => s !== server);
209
244
  });
245
+ });
246
+ }
210
247
 
211
- conn.connect(config);
248
+ public closeForwardedPorts(): void {
249
+ this.logger.info('[SSH] Closing all forwarded ports...');
250
+ this.forwardedServers.forEach((server) => {
251
+ const rawAddress = server.address();
252
+ if (rawAddress && typeof rawAddress !== 'string') {
253
+ const address: net.AddressInfo = rawAddress;
254
+ this.logger.info(`[SSH] Closing port forward for server ${address.address}:${address.port}`);
255
+ }
256
+
257
+ server.close();
212
258
  });
259
+ this.forwardedServers = [];
213
260
  }
214
261
 
215
262
  /**
@@ -440,7 +487,7 @@ export class SshClient {
440
487
 
441
488
  public uploadFileUsingExistingSftp(sftp: SFTPWrapper, localPath: string, remotePath: string, mode: number = 0o660) {
442
489
  return new Promise((resolve, reject) => {
443
- readFile(localPath).then(async (result) => {
490
+ readFile(localPath).then(async (result: Buffer) => {
444
491
  this.writeFile(sftp, remotePath, result, mode)
445
492
  .then(() => {
446
493
  resolve(undefined);
@@ -590,11 +637,49 @@ export class SshClient {
590
637
  }
591
638
 
592
639
  /**
593
- * Closes the SSH client connection.
640
+ * Closes the SSH client connection and forwarded ports.
594
641
  */
595
642
  public close(): void {
643
+ this.closeForwardedPorts();
596
644
  this.client.end();
597
645
  }
598
646
  }
599
647
 
600
648
  export type SshExecResult = { stdout: string; stderr: string };
649
+
650
+ async function connect(
651
+ client: Client,
652
+ config: ConnectConfig,
653
+ onError: (e: unknown) => void,
654
+ onClose: () => void,
655
+ ): Promise<Client> {
656
+ return new Promise((resolve, reject) => {
657
+ client.on('ready', () => {
658
+ resolve(client);
659
+ });
660
+
661
+ client.on('error', (err: unknown) => {
662
+ onError(err);
663
+ reject(new Error(`ssh.connect: error occurred: ${err}`));
664
+ });
665
+
666
+ client.on('close', () => {
667
+ onClose();
668
+ });
669
+
670
+ client.connect(config);
671
+ });
672
+ }
673
+
674
+ async function forwardOut(logger: MiLogger, conn: Client, localHost: string, localPort: number, remoteHost: string, remotePort: number): Promise<ClientChannel> {
675
+ return new Promise((resolve, reject) => {
676
+ conn.forwardOut(localHost, localPort, remoteHost, remotePort, (err, stream) => {
677
+ if (err) {
678
+ logger.error(`forwardOut.error: ${err}`);
679
+ return reject(err);
680
+ }
681
+
682
+ return resolve(stream);
683
+ });
684
+ });
685
+ }