@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.
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +470 -392
- package/dist/index.mjs.map +1 -1
- package/dist/ssh/pl.d.ts +1 -0
- package/dist/ssh/pl.d.ts.map +1 -1
- package/dist/ssh/ssh.d.ts +12 -2
- package/dist/ssh/ssh.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/ssh/__tests__/common-utils.ts +1 -1
- package/src/ssh/__tests__/ssh-docker.test.ts +19 -0
- package/src/ssh/pl.ts +17 -6
- package/src/ssh/ssh.ts +198 -56
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
this.logger.
|
|
191
|
-
server
|
|
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
|
-
|
|
196
|
-
|
|
230
|
+
server.on('error', (err) => {
|
|
231
|
+
server.close();
|
|
232
|
+
reject(new Error(`${log}.server: error: ${JSON.stringify(err)}`));
|
|
197
233
|
});
|
|
198
234
|
|
|
199
|
-
|
|
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
|
+
}
|