@milaboratories/pl-deployments 1.1.3 → 1.1.5

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.
@@ -131,6 +131,25 @@ describe('SSH Tests', () => {
131
131
  server.close();
132
132
  });
133
133
 
134
+ it('Remove directory', async () => {
135
+ const rootFolder = '/home/pl-doctor/upload';
136
+ await client.createRemoteDirectory(`${rootFolder}/upload/nested`);
137
+ await client.createRemoteDirectory(`${rootFolder}/2-nested`);
138
+ await client.createRemoteDirectory(`${rootFolder}/2-nested/3-sub`);
139
+ await client.writeFileOnTheServer(`${rootFolder}/2-nested/3-sub/qwerty.txt`, 'HELLO FROM SSH');
140
+
141
+ const text = await client.readFile(`${rootFolder}/2-nested/3-sub/qwerty.txt`);
142
+ expect(text).toBe('HELLO FROM SSH');
143
+
144
+ let data = await client.checkPathExists(rootFolder);
145
+ expect(data.exists).toBe(true);
146
+
147
+ await client.deleteFolder(rootFolder);
148
+
149
+ data = await client.checkPathExists(rootFolder);
150
+ expect(data.exists).toBe(false);
151
+ });
152
+
134
153
  it('Auth types', async () => {
135
154
  const hostData = getContainerHostAndPort(testContainer);
136
155
  const types = await SshClient.getAuthTypes(hostData.host, hostData.port);
package/src/ssh/pl.ts CHANGED
@@ -60,7 +60,7 @@ export class SshPl {
60
60
  // We are waiting for Platforma to run to ensure that it has started.
61
61
  return await this.checkIsAliveWithInterval();
62
62
  } catch (e: unknown) {
63
- const msg = `ssh.start: error occurred ${e}`
63
+ const msg = `ssh.start: error occurred ${e}`;
64
64
  this.logger.error(msg);
65
65
  throw new Error(msg);
66
66
  }
@@ -74,12 +74,24 @@ export class SshPl {
74
74
  await supervisorCtlShutdown(this.sshClient, remoteHome, arch.arch);
75
75
  return await this.checkIsAliveWithInterval(undefined, undefined, false);
76
76
  } catch (e: unknown) {
77
- const msg = `ssh.stop: error occurred ${e}`
77
+ const msg = `ssh.stop: error occurred ${e}`;
78
78
  this.logger.error(msg);
79
- throw new Error(msg)
79
+ throw new Error(msg);
80
80
  }
81
81
  }
82
82
 
83
+ public async reset(): Promise<boolean> {
84
+ const workDir = await this.getUserHomeDirectory();
85
+
86
+ this.logger.info(`pl.reset: Stop Platforma on the server`);
87
+ await this.stop();
88
+
89
+ this.logger.info(`pl.reset: Deleting Platforma workDir ${workDir} on the server`);
90
+ await this.sshClient.deleteFolder(plpath.workDir(workDir));
91
+
92
+ return true;
93
+ }
94
+
83
95
  public async platformaInit(localWorkdir: string): Promise<SshInitReturnTypes> {
84
96
  const state: PlatformaInitState = { localWorkdir };
85
97
 
@@ -232,7 +244,6 @@ export class SshPl {
232
244
  softwareName: string,
233
245
  tgzName: string,
234
246
  ): Promise<DownloadAndUntarState> {
235
-
236
247
  const state: DownloadAndUntarState = {};
237
248
  state.binBasePath = plpath.binariesDir(remoteHome);
238
249
  await this.sshClient.createRemoteDirectory(state.binBasePath);
@@ -242,7 +253,7 @@ export class SshPl {
242
253
  const attempts = 5;
243
254
  for (let i = 1; i <= attempts; i++) {
244
255
  try {
245
- downloadBinaryResult = await downloadBinaryNoExtract(
256
+ downloadBinaryResult = await downloadBinaryNoExtract(
246
257
  this.logger,
247
258
  localWorkdir,
248
259
  softwareName,
@@ -250,7 +261,7 @@ export class SshPl {
250
261
  arch.arch, arch.platform,
251
262
  );
252
263
  break;
253
- } catch(e: unknown) {
264
+ } catch (e: unknown) {
254
265
  await sleep(300);
255
266
  if (i == attempts) {
256
267
  throw new Error(`downloadAndUntar: ${attempts} attempts, last error: ${e}`);
package/src/ssh/ssh.ts CHANGED
@@ -5,15 +5,20 @@ 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,
12
13
  keepaliveCountMax: 10,
13
- }
14
+ };
14
15
 
15
16
  export type SshAuthMethods = 'publickey' | 'password';
16
17
  export type SshAuthMethodsResult = SshAuthMethods[];
18
+ export type SshDirContent = {
19
+ files: string[];
20
+ directories: string[];
21
+ };
17
22
 
18
23
  export class SshClient {
19
24
  private config?: ConnectConfig;
@@ -32,7 +37,7 @@ export class SshClient {
32
37
  public static async init(logger: MiLogger, config: ConnectConfig): Promise<SshClient> {
33
38
  const withDefaults = {
34
39
  ...defaultConfig,
35
- ...config
40
+ ...config,
36
41
  };
37
42
 
38
43
  const client = new SshClient(logger, new Client());
@@ -41,6 +46,14 @@ export class SshClient {
41
46
  return client;
42
47
  }
43
48
 
49
+ public getFullHostName() {
50
+ return `${this.config?.host}:${this.config?.port}`;
51
+ }
52
+
53
+ public getUserName() {
54
+ return this.config?.username;
55
+ }
56
+
44
57
  /**
45
58
  * Connects to the SSH server using the specified configuration.
46
59
  * @param config - The connection configuration object for the SSH client.
@@ -48,16 +61,7 @@ export class SshClient {
48
61
  */
49
62
  public async connect(config: ConnectConfig) {
50
63
  this.config = config;
51
- return new Promise((resolve, reject) => {
52
- this.client.on('ready', () => {
53
- resolve(undefined);
54
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
- }).on('error', (err: any) => {
56
- reject(new Error(`ssh.connect: error occurred: ${err}`));
57
- }).on('timeout', () => {
58
- reject(new Error(`timeout was occurred while waiting for SSH connection.`));
59
- }).connect(config);
60
- });
64
+ return await connect(this.client, config, () => {}, () => {});
61
65
  }
62
66
 
63
67
  /**
@@ -142,61 +146,95 @@ export class SshClient {
142
146
  * @returns { server: net.Server } A promise resolving with the created server instance.
143
147
  */
144
148
  public async forwardPort(ports: { remotePort: number; localPort: number; localHost?: string }, config?: ConnectConfig): Promise<{ server: net.Server }> {
149
+ const log = `ssh.forward:${ports.localPort}:${ports.remotePort}.id_${randomBytes(1).toString('hex')}`;
145
150
  config = config ?? this.config;
146
- return new Promise((resolve, reject) => {
147
- if (!config) {
148
- reject('No config defined');
149
- return;
150
- }
151
- const conn = new Client();
152
- let server: net.Server;
153
- conn.on('ready', () => {
154
- this.logger.info(`[SSH] Connection to ${config.host}. Remote port ${ports.remotePort} will be available locally on the ${ports.localPort}`);
155
- server = net.createServer({ pauseOnConnect: true }, (localSocket) => {
156
- conn.forwardOut('127.0.0.1', 0, '127.0.0.1', ports.remotePort,
157
- (err, stream) => {
158
- if (err) {
159
- console.error('Error opening SSH channel:', err.message);
160
- localSocket.end();
161
- return;
162
- }
163
- localSocket.pipe(stream);
164
- stream.pipe(localSocket);
165
- localSocket.resume();
166
- },
167
- );
151
+
152
+ // we make this thing persistent so that if the connection
153
+ // drops (it happened in the past because of lots of errors and forwardOut opened channels),
154
+ // we'll recreate it here.
155
+ const persistentClient = new RetryablePromise((p: RetryablePromise<Client>) => {
156
+ return new Promise<Client>((resolve, reject) => {
157
+ const client = new Client();
158
+
159
+ client.on('ready', () => {
160
+ this.logger.info(`${log}.client.ready`);
161
+ resolve(client);
168
162
  });
169
- server.listen(ports.localPort, '127.0.0.1', () => {
170
- this.logger.info(`[+] Port local ${ports.localPort} available locally for remote port → :${ports.remotePort}`);
171
- resolve({ server });
163
+
164
+ client.on('error', (err) => {
165
+ this.logger.info(`${log}.client.error: ${err}`);
166
+ p.reset();
167
+ reject(err);
172
168
  });
173
169
 
174
- server.on('error', (err) => {
175
- conn.end();
176
- server.close();
177
- reject(new Error(`ssh.forwardPort: server error: ${err}`));
170
+ client.on('close', () => {
171
+ this.logger.info(`${log}.client.closed`);
172
+ p.reset();
178
173
  });
179
174
 
180
- server.on('close', () => {
181
- this.logger.info(`Server closed ${JSON.stringify(ports)}`);
182
- if (conn) {
183
- this.logger.info(`End SSH connection`);
184
- conn.end();
185
- }
175
+ client.connect(config!);
176
+ });
177
+ });
178
+
179
+ await persistentClient.ensure(); // warm up a connection
180
+
181
+ return new Promise((resolve, reject) => {
182
+ const server = net.createServer({ pauseOnConnect: true }, async (localSocket) => {
183
+ const sockLog = `${log}.sock_${randomBytes(1).toString('hex')}`;
184
+ // this.logger.info(`${sockLog}.localSocket: start connection`);
185
+ let conn: Client;
186
+ try {
187
+ conn = await persistentClient.ensure();
188
+ } catch (e: unknown) {
189
+ this.logger.info(`${sockLog}.persistentClient.catch: ${e}`);
190
+ localSocket.end();
191
+ return;
192
+ }
193
+
194
+ let stream: ClientChannel;
195
+ try {
196
+ stream = await forwardOut(this.logger, conn, '127.0.0.1', 0, '127.0.0.1', ports.remotePort);
197
+ } catch (e: unknown) {
198
+ this.logger.error(`${sockLog}.forwardOut.err: ${e}`);
199
+ localSocket.end();
200
+ return;
201
+ }
202
+
203
+ localSocket.pipe(stream);
204
+ stream.pipe(localSocket);
205
+ localSocket.resume();
206
+ // this.logger.info(`${sockLog}.forwardOut: connected`);
207
+
208
+ stream.on('error', (err: unknown) => {
209
+ this.logger.error(`${sockLog}.stream.error: ${err}`);
210
+ localSocket.end();
211
+ stream.end();
212
+ });
213
+ stream.on('close', () => {
214
+ // this.logger.info(`${sockLog}.stream.close: closed`);
215
+ localSocket.end();
216
+ stream.end();
217
+ });
218
+ localSocket.on('close', () => {
219
+ this.logger.info(`${sockLog}.localSocket: closed`);
220
+ localSocket.end();
221
+ stream.end();
186
222
  });
187
223
  });
188
224
 
189
- conn.on('error', (err) => {
190
- this.logger.error(`[SSH] SSH connection error, ports: ${JSON.stringify(ports)}, err: ${err.message}`);
191
- server?.close();
192
- reject(`ssh.forwardPort: conn.err: ${err}`);
225
+ server.listen(ports.localPort, '127.0.0.1', () => {
226
+ this.logger.info(`${log}.server: started listening`);
227
+ resolve({ server });
193
228
  });
194
229
 
195
- conn.on('close', () => {
196
- this.logger.info(`[SSH] Connection closed, ports: ${JSON.stringify(ports)}`);
230
+ server.on('error', (err) => {
231
+ server.close();
232
+ reject(new Error(`${log}.server: error: ${JSON.stringify(err)}`));
197
233
  });
198
234
 
199
- conn.connect(config);
235
+ server.on('close', () => {
236
+ this.logger.info(`${log}.server: closed ${JSON.stringify(ports)}`);
237
+ });
200
238
  });
201
239
  }
202
240
 
@@ -286,6 +324,73 @@ export class SshClient {
286
324
  });
287
325
  }
288
326
 
327
+ public async getForderStructure(sftp: SFTPWrapper, remotePath: string, data: SshDirContent = { files: [], directories: [] }): Promise<SshDirContent> {
328
+ return new Promise((resolve, reject) => {
329
+ sftp.readdir(remotePath, async (err, items) => {
330
+ if (err) {
331
+ return reject(err);
332
+ }
333
+
334
+ for (const item of items) {
335
+ const itemPath = `${remotePath}/${item.filename}`;
336
+ if (item.attrs.isDirectory()) {
337
+ data.directories.push(itemPath);
338
+ try {
339
+ await this.getForderStructure(sftp, itemPath, data);
340
+ } catch (error) {
341
+ return reject(error);
342
+ }
343
+ } else {
344
+ data.files.push(itemPath);
345
+ }
346
+ }
347
+ resolve(data);
348
+ });
349
+ });
350
+ }
351
+
352
+ public rmdir(sftp: SFTPWrapper, path: string) {
353
+ return new Promise((resolve, reject) => {
354
+ sftp.rmdir(path, (err) => err ? reject(err) : resolve(true));
355
+ });
356
+ }
357
+
358
+ public unlink(sftp: SFTPWrapper, path: string) {
359
+ return new Promise((resolve, reject) => {
360
+ sftp.unlink(path, (err) => err ? reject(err) : resolve(true));
361
+ });
362
+ }
363
+
364
+ public async deleteFolder(path: string) {
365
+ return this.withSftp(async (sftp) => {
366
+ try {
367
+ const list = await this.getForderStructure(sftp, path);
368
+ this.logger.info(`ssh.deleteFolder list of files and directories`);
369
+ this.logger.info(`ssh.deleteFolder list of files: ${list.files}`);
370
+ this.logger.info(`ssh.deleteFolder list of directories: ${list.directories}`);
371
+
372
+ for (const filePath of list.files) {
373
+ this.logger.info(`ssh.deleteFolder unlink file ${filePath}`);
374
+ await this.unlink(sftp, filePath);
375
+ }
376
+
377
+ list.directories.sort((a, b) => b.length - a.length);
378
+
379
+ for (const directoryPath of list.directories) {
380
+ this.logger.info(`ssh.deleteFolder rmdir ${directoryPath}`);
381
+ await this.rmdir(sftp, directoryPath);
382
+ }
383
+
384
+ await this.rmdir(sftp, path);
385
+ return true;
386
+ } catch (e: unknown) {
387
+ this.logger.error(e);
388
+ const message = e instanceof Error ? e.message : '';
389
+ throw new Error(`ssh.deleteFolder: path: ${path}, message: ${message}`);
390
+ }
391
+ });
392
+ }
393
+
289
394
  public async readFile(remotePath: string): Promise<string> {
290
395
  return this.withSftp(async (sftp) => {
291
396
  return new Promise((resolve, reject) => {
@@ -361,7 +466,7 @@ export class SshClient {
361
466
 
362
467
  public uploadFileUsingExistingSftp(sftp: SFTPWrapper, localPath: string, remotePath: string, mode: number = 0o660) {
363
468
  return new Promise((resolve, reject) => {
364
- readFile(localPath).then(async (result) => {
469
+ readFile(localPath).then(async (result: Buffer) => {
365
470
  this.writeFile(sftp, remotePath, result, mode)
366
471
  .then(() => {
367
472
  resolve(undefined);
@@ -519,3 +624,40 @@ export class SshClient {
519
624
  }
520
625
 
521
626
  export type SshExecResult = { stdout: string; stderr: string };
627
+
628
+ async function connect(
629
+ client: Client,
630
+ config: ConnectConfig,
631
+ onError: (e: unknown) => void,
632
+ onClose: () => void,
633
+ ): Promise<Client> {
634
+ return new Promise((resolve, reject) => {
635
+ client.on('ready', () => {
636
+ resolve(client);
637
+ });
638
+
639
+ client.on('error', (err: unknown) => {
640
+ onError(err);
641
+ reject(new Error(`ssh.connect: error occurred: ${err}`));
642
+ });
643
+
644
+ client.on('close', () => {
645
+ onClose();
646
+ });
647
+
648
+ client.connect(config);
649
+ });
650
+ }
651
+
652
+ async function forwardOut(logger: MiLogger, conn: Client, localHost: string, localPort: number, remoteHost: string, remotePort: number): Promise<ClientChannel> {
653
+ return new Promise((resolve, reject) => {
654
+ conn.forwardOut(localHost, localPort, remoteHost, remotePort, (err, stream) => {
655
+ if (err) {
656
+ logger.error(`forwardOut.error: ${err}`);
657
+ return reject(err);
658
+ }
659
+
660
+ return resolve(stream);
661
+ });
662
+ });
663
+ }