@milaboratories/pl-deployments 1.1.0
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/README.md +1 -0
- package/dist/common/os_and_arch.d.ts +9 -0
- package/dist/common/os_and_arch.d.ts.map +1 -0
- package/dist/common/pl_binary.d.ts +14 -0
- package/dist/common/pl_binary.d.ts.map +1 -0
- package/dist/common/pl_binary_download.d.ts +30 -0
- package/dist/common/pl_binary_download.d.ts.map +1 -0
- package/dist/common/pl_version.d.ts +2 -0
- package/dist/common/pl_version.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1028 -0
- package/dist/index.mjs.map +1 -0
- package/dist/local/options.d.ts +31 -0
- package/dist/local/options.d.ts.map +1 -0
- package/dist/local/pid.d.ts +4 -0
- package/dist/local/pid.d.ts.map +1 -0
- package/dist/local/pl.d.ts +66 -0
- package/dist/local/pl.d.ts.map +1 -0
- package/dist/local/process.d.ts +12 -0
- package/dist/local/process.d.ts.map +1 -0
- package/dist/local/trace.d.ts +10 -0
- package/dist/local/trace.d.ts.map +1 -0
- package/dist/ssh/__tests__/common-utils.d.ts +18 -0
- package/dist/ssh/__tests__/common-utils.d.ts.map +1 -0
- package/dist/ssh/pl.d.ts +101 -0
- package/dist/ssh/pl.d.ts.map +1 -0
- package/dist/ssh/pl_paths.d.ts +17 -0
- package/dist/ssh/pl_paths.d.ts.map +1 -0
- package/dist/ssh/ssh.d.ts +128 -0
- package/dist/ssh/ssh.d.ts.map +1 -0
- package/dist/ssh/supervisord.d.ts +8 -0
- package/dist/ssh/supervisord.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/common/os_and_arch.ts +44 -0
- package/src/common/pl_binary.ts +39 -0
- package/src/common/pl_binary_download.ts +258 -0
- package/src/common/pl_version.ts +5 -0
- package/src/index.ts +4 -0
- package/src/local/config.test.yaml +60 -0
- package/src/local/options.ts +34 -0
- package/src/local/pid.ts +21 -0
- package/src/local/pl.test.ts +129 -0
- package/src/local/pl.ts +220 -0
- package/src/local/process.ts +44 -0
- package/src/local/trace.ts +30 -0
- package/src/ssh/Dockerfile +29 -0
- package/src/ssh/__tests__/common-utils.ts +131 -0
- package/src/ssh/__tests__/pl-docker.test.ts +192 -0
- package/src/ssh/__tests__/ssh-docker.test.ts +175 -0
- package/src/ssh/pl.ts +451 -0
- package/src/ssh/pl_paths.ts +57 -0
- package/src/ssh/ssh.ts +512 -0
- package/src/ssh/supervisord.ts +137 -0
- package/src/ssh/test-assets/simple-server.js +10 -0
package/src/ssh/ssh.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import type { ConnectConfig, ClientChannel, SFTPWrapper } from 'ssh2';
|
|
2
|
+
import ssh, { Client } from 'ssh2';
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import dns from 'dns';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { readFile } from 'fs/promises';
|
|
7
|
+
import upath from 'upath';
|
|
8
|
+
import type { MiLogger } from '@milaboratories/ts-helpers';
|
|
9
|
+
import { fileExists } from '@milaboratories/ts-helpers';
|
|
10
|
+
|
|
11
|
+
export type SshAuthMethods = 'publickey' | 'password';
|
|
12
|
+
export type SshAuthMethodsResult = SshAuthMethods[];
|
|
13
|
+
|
|
14
|
+
export class SshClient {
|
|
15
|
+
private config?: ConnectConfig;
|
|
16
|
+
public homeDir?: string;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly logger: MiLogger,
|
|
20
|
+
private readonly client: Client,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initializes the SshClient and establishes a connection using the provided configuration.
|
|
25
|
+
* @param config - The connection configuration object for the SSH client.
|
|
26
|
+
* @returns A new instance of SshClient with an active connection.
|
|
27
|
+
*/
|
|
28
|
+
public static async init(logger: MiLogger, config: ConnectConfig): Promise<SshClient> {
|
|
29
|
+
const client = new SshClient(logger, new Client());
|
|
30
|
+
await client.connect(config);
|
|
31
|
+
|
|
32
|
+
return client;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Connects to the SSH server using the specified configuration.
|
|
37
|
+
* @param config - The connection configuration object for the SSH client.
|
|
38
|
+
* @returns A promise that resolves when the connection is established or rejects on error.
|
|
39
|
+
*/
|
|
40
|
+
public async connect(config: ConnectConfig) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
this.client.on('ready', () => {
|
|
44
|
+
resolve(undefined);
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
}).on('error', (err: any) => {
|
|
47
|
+
reject(new Error(`ssh.connect: error occurred: ${err}`));
|
|
48
|
+
}).on('timeout', () => {
|
|
49
|
+
reject(new Error(`timeout was occurred while waiting for SSH connection.`));
|
|
50
|
+
}).connect(config);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Executes a command on the SSH server.
|
|
56
|
+
* @param command - The command to execute on the remote server.
|
|
57
|
+
* @returns A promise resolving with the command's stdout and stderr outputs.
|
|
58
|
+
*/
|
|
59
|
+
public async exec(command: string): Promise<SshExecResult> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
this.client.exec(command, (err: any, stream: ClientChannel) => {
|
|
63
|
+
if (err) {
|
|
64
|
+
return reject(`ssh.exec: ${command}, error occurred: ${err}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let stdout = '';
|
|
68
|
+
let stderr = '';
|
|
69
|
+
|
|
70
|
+
stream.on('close', (code: number) => {
|
|
71
|
+
if (code === 0) {
|
|
72
|
+
resolve({ stdout, stderr });
|
|
73
|
+
} else {
|
|
74
|
+
reject(new Error(`Command ${command} exited with code ${code}`));
|
|
75
|
+
}
|
|
76
|
+
}).on('data', (data: ArrayBuffer) => {
|
|
77
|
+
stdout += data.toString();
|
|
78
|
+
}).stderr.on('data', (data: ArrayBuffer) => {
|
|
79
|
+
stderr += data.toString();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Retrieves the supported authentication methods for a given host and port.
|
|
87
|
+
* @param host - The hostname or IP address of the server.
|
|
88
|
+
* @param port - The port number to connect to on the server.
|
|
89
|
+
* @returns 'publickey' | 'password'[] A promise resolving with a list of supported authentication methods.
|
|
90
|
+
*/
|
|
91
|
+
public static async getAuthTypes(host: string, port: number): Promise<SshAuthMethodsResult> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let stdout = '';
|
|
94
|
+
const conn = new Client();
|
|
95
|
+
|
|
96
|
+
conn.on('ready', () => {
|
|
97
|
+
conn.end();
|
|
98
|
+
const types = this.extractAuthMethods(stdout);
|
|
99
|
+
resolve(types.length === 0 ? ['publickey', 'password'] : types as SshAuthMethodsResult);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
conn.on('error', () => {
|
|
103
|
+
conn.end();
|
|
104
|
+
resolve(['publickey', 'password']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
conn.connect({
|
|
108
|
+
host,
|
|
109
|
+
port,
|
|
110
|
+
username: new Date().getTime().toString(),
|
|
111
|
+
debug: (err) => {
|
|
112
|
+
stdout += `${err}\n`;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extracts authentication methods from debug logs.
|
|
120
|
+
* @param log - The debug log output containing authentication information.
|
|
121
|
+
* @returns An array of extracted authentication methods.
|
|
122
|
+
*/
|
|
123
|
+
private static extractAuthMethods(log: string): string[] {
|
|
124
|
+
const match = log.match(/Inbound: Received USERAUTH_FAILURE \((.+)\)/);
|
|
125
|
+
return match && match[1] ? match[1].split(',').map((method) => method.trim()) : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sets up port forwarding between a remote port on the SSH server and a local port.
|
|
130
|
+
* A new connection is used for this operation instead of an existing one.
|
|
131
|
+
* @param ports - An object specifying the remote and local port configuration.
|
|
132
|
+
* @param config - Optional connection configuration for the SSH client.
|
|
133
|
+
* @returns { server: net.Server } A promise resolving with the created server instance.
|
|
134
|
+
*/
|
|
135
|
+
public async forwardPort(ports: { remotePort: number; localPort: number; localHost?: string }, config?: ConnectConfig): Promise<{ server: net.Server }> {
|
|
136
|
+
config = config ?? this.config;
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
if (!config) {
|
|
139
|
+
reject('No config defined');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const conn = new Client();
|
|
143
|
+
let server: net.Server;
|
|
144
|
+
conn.on('ready', () => {
|
|
145
|
+
this.logger.info(`[SSH] Connection to ${config.host}. Remote port ${ports.remotePort} will be available locally on the ${ports.localPort}`);
|
|
146
|
+
server = net.createServer({ pauseOnConnect: true }, (localSocket) => {
|
|
147
|
+
conn.forwardOut('127.0.0.1', 0, '127.0.0.1', ports.remotePort,
|
|
148
|
+
(err, stream) => {
|
|
149
|
+
if (err) {
|
|
150
|
+
console.error('Error opening SSH channel:', err.message);
|
|
151
|
+
localSocket.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
localSocket.pipe(stream);
|
|
155
|
+
stream.pipe(localSocket);
|
|
156
|
+
localSocket.resume();
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
server.listen(ports.localPort, '127.0.0.1', () => {
|
|
161
|
+
this.logger.info(`[+] Port local ${ports.localPort} available locally for remote port → :${ports.remotePort}`);
|
|
162
|
+
resolve({ server });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
server.on('error', (err) => {
|
|
166
|
+
conn.end();
|
|
167
|
+
server.close();
|
|
168
|
+
reject(new Error(`ssh.forwardPort: server error: ${err}`));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
server.on('close', () => {
|
|
172
|
+
this.logger.info(`Server closed ${JSON.stringify(ports)}`);
|
|
173
|
+
if (conn) {
|
|
174
|
+
this.logger.info(`End SSH connection`);
|
|
175
|
+
conn.end();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
conn.on('error', (err) => {
|
|
181
|
+
console.error('[SSH] SSH connection error', 'ports', ports, err.message);
|
|
182
|
+
server?.close();
|
|
183
|
+
reject(`ssh.forwardPort: conn.err: ${err}`);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
conn.on('close', () => {
|
|
187
|
+
this.logger.info(`[SSH] Connection closed, ports: ${ports}`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
conn.connect(config);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Checks if a specified host is available by performing a DNS lookup.
|
|
196
|
+
* @param hostname - The hostname or IP address to check.
|
|
197
|
+
* @returns A promise resolving with `true` if the host is reachable, otherwise `false`.
|
|
198
|
+
*/
|
|
199
|
+
public static async checkHostAvailability(hostname: string): Promise<boolean> {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
dns.lookup(hostname, (err) => {
|
|
202
|
+
resolve(!err);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Determines whether a private key requires a passphrase for use.
|
|
209
|
+
* @param privateKey - The private key content to check.
|
|
210
|
+
* @returns A promise resolving with `true` if a passphrase is required, otherwise `false`.
|
|
211
|
+
*/
|
|
212
|
+
public static async isPassphraseRequiredForKey(privateKey: string): Promise<boolean> {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
try {
|
|
215
|
+
const keyOrError = ssh.utils.parseKey(privateKey);
|
|
216
|
+
if (keyOrError instanceof Error) {
|
|
217
|
+
resolve(true);
|
|
218
|
+
}
|
|
219
|
+
return resolve(false);
|
|
220
|
+
} catch (err: unknown) {
|
|
221
|
+
console.log('Error parsing privateKey');
|
|
222
|
+
reject(new Error(`ssh.isPassphraseRequiredForKey: err ${err}`));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Uploads a local file to a remote server via SFTP.
|
|
229
|
+
* This function creates new SFTP connection
|
|
230
|
+
* @param localPath - The local file path.
|
|
231
|
+
* @param remotePath - The remote file path on the server.
|
|
232
|
+
* @returns A promise resolving with `true` if the file was successfully uploaded.
|
|
233
|
+
*/
|
|
234
|
+
public async uploadFile(localPath: string, remotePath: string): Promise<boolean> {
|
|
235
|
+
return await this.withSftp(async (sftp) => {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
238
|
+
if (err) {
|
|
239
|
+
const newErr = new Error(
|
|
240
|
+
`ssh.uploadFile: err: ${err}, localPath: ${localPath}, remotePath: ${remotePath}`);
|
|
241
|
+
return reject(newErr);
|
|
242
|
+
}
|
|
243
|
+
resolve(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
delay(delay: number): Promise<void> {
|
|
250
|
+
return new Promise((res, rej) => {
|
|
251
|
+
setTimeout(() => res(), delay);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public async withSftp<R>(callback: (sftp: SFTPWrapper) => Promise<R>): Promise<R> {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
this.client.sftp((err, sftp) => {
|
|
258
|
+
if (err) {
|
|
259
|
+
return reject(new Error(`ssh.withSftp: sftp err: ${err}`));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
callback(sftp)
|
|
263
|
+
.then(resolve)
|
|
264
|
+
.catch((err) => {
|
|
265
|
+
reject(new Error(`ssh.withSftp.callback: err ${err}`));
|
|
266
|
+
})
|
|
267
|
+
.finally(() => {
|
|
268
|
+
sftp?.end();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
public async writeFileOnTheServer(remotePath: string, data: string | Buffer, mode: number = 0o660) {
|
|
275
|
+
return this.withSftp(async (sftp) => {
|
|
276
|
+
return this.writeFile(sftp, remotePath, data, mode);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
public async readFile(remotePath: string): Promise<string> {
|
|
281
|
+
return this.withSftp(async (sftp) => {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
sftp.readFile(remotePath, (err, buffer) => {
|
|
284
|
+
if (err) {
|
|
285
|
+
return reject(new Error(`ssh.readFile: err occurred ${err}`));
|
|
286
|
+
}
|
|
287
|
+
resolve(buffer.toString());
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async chmod(path: string, mode: number) {
|
|
294
|
+
return this.withSftp(async (sftp) => {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
sftp.chmod(path, mode, (err) => {
|
|
297
|
+
if (err) {
|
|
298
|
+
return reject(new Error(`ssh.chmod: ${err}, path: ${path}, mode: ${mode}`));
|
|
299
|
+
}
|
|
300
|
+
return resolve(undefined);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async checkFileExists(remotePath: string) {
|
|
307
|
+
return this.withSftp(async (sftp) => {
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
310
|
+
if (err) {
|
|
311
|
+
if ((err as Error & { code: number }).code === 2) {
|
|
312
|
+
return resolve(false);
|
|
313
|
+
}
|
|
314
|
+
return reject(new Error(`ssh.checkFileExists: err ${err}`));
|
|
315
|
+
}
|
|
316
|
+
resolve(stats.isFile());
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async checkPathExists(remotePath: string): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }> {
|
|
323
|
+
return this.withSftp(async (sftp) => {
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
326
|
+
if (err) {
|
|
327
|
+
if ((err as Error & { code: number }).code === 2) {
|
|
328
|
+
return resolve({ exists: false, isFile: false, isDirectory: false });
|
|
329
|
+
}
|
|
330
|
+
return reject(new Error(`ssh.checkPathExists: ${err}`));
|
|
331
|
+
}
|
|
332
|
+
resolve({
|
|
333
|
+
exists: true,
|
|
334
|
+
isFile: stats.isFile(),
|
|
335
|
+
isDirectory: stats.isDirectory(),
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async writeFile(sftp: SFTPWrapper, remotePath: string, data: string | Buffer, mode: number = 0o660): Promise<boolean> {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
sftp.writeFile(remotePath, data, { mode }, (err) => {
|
|
345
|
+
if (err) {
|
|
346
|
+
return reject(new Error(`ssh.writeFile: err ${err}, remotePath: ${remotePath}`));
|
|
347
|
+
}
|
|
348
|
+
resolve(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
public uploadFileUsingExistingSftp(sftp: SFTPWrapper, localPath: string, remotePath: string, mode: number = 0o660) {
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
readFile(localPath).then(async (result) => {
|
|
356
|
+
this.writeFile(sftp, remotePath, result, mode)
|
|
357
|
+
.then(() => {
|
|
358
|
+
resolve(undefined);
|
|
359
|
+
})
|
|
360
|
+
.catch((err) => {
|
|
361
|
+
const msg = `uploadFileUsingExistingSftp: error ${err} occurred`;
|
|
362
|
+
this.logger.error(msg);
|
|
363
|
+
reject(new Error(msg));
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private async __uploadDirectory(sftp: SFTPWrapper, localDir: string, remoteDir: string, mode: number = 0o660): Promise<void> {
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
fs.readdir(localDir, async (err, files) => {
|
|
372
|
+
if (err) {
|
|
373
|
+
return reject(new Error(`ssh.__uploadDir: err ${err}, localDir: ${localDir}, remoteDir: ${remoteDir}`));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
await this.__createRemoteDirectory(sftp, remoteDir);
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
const localPath = upath.join(localDir, file);
|
|
380
|
+
const remotePath = `${remoteDir}/${file}`;
|
|
381
|
+
|
|
382
|
+
if (fs.lstatSync(localPath).isDirectory()) {
|
|
383
|
+
await this.__uploadDirectory(sftp, localPath, remotePath, mode);
|
|
384
|
+
} else {
|
|
385
|
+
await this.uploadFileUsingExistingSftp(sftp, localPath, remotePath, mode);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
resolve();
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const msg = `ssh.__uploadDir: catched err ${err}`;
|
|
392
|
+
this.logger.error(msg);
|
|
393
|
+
reject(new Error(msg));
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Uploads a local directory and its contents (including subdirectories) to the remote server via SFTP.
|
|
401
|
+
* @param localDir - The path to the local directory to upload.
|
|
402
|
+
* @param remoteDir - The path to the remote directory on the server.
|
|
403
|
+
* @returns A promise that resolves when the directory and its contents are uploaded.
|
|
404
|
+
*/
|
|
405
|
+
public async uploadDirectory(localDir: string, remoteDir: string, mode: number = 0o660): Promise<void> {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
this.withSftp(async (sftp: SFTPWrapper) => {
|
|
408
|
+
await this.__uploadDirectory(sftp, localDir, remoteDir, mode);
|
|
409
|
+
resolve();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Ensures that a remote directory and all its parent directories exist.
|
|
416
|
+
* @param sftp - The SFTP wrapper.
|
|
417
|
+
* @param remotePath - The path to the remote directory.
|
|
418
|
+
* @returns A promise that resolves when the directory is created.
|
|
419
|
+
*/
|
|
420
|
+
private __createRemoteDirectory(sftp: SFTPWrapper, remotePath: string): Promise<void> {
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
const directories = remotePath.split('/');
|
|
423
|
+
let currentPath = '';
|
|
424
|
+
|
|
425
|
+
const createNext = (index: number) => {
|
|
426
|
+
if (index >= directories.length) {
|
|
427
|
+
return resolve();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
currentPath += `${directories[index]}/`;
|
|
431
|
+
|
|
432
|
+
sftp.stat(currentPath, (err) => {
|
|
433
|
+
if (err) {
|
|
434
|
+
sftp.mkdir(currentPath, (err) => {
|
|
435
|
+
if (err) {
|
|
436
|
+
return reject(new Error(`ssh.__createRemDir: err ${err}, remotePath: ${remotePath}`));
|
|
437
|
+
}
|
|
438
|
+
createNext(index + 1);
|
|
439
|
+
});
|
|
440
|
+
} else {
|
|
441
|
+
createNext(index + 1);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
createNext(0);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Ensures that a remote directory and all its parent directories exist.
|
|
452
|
+
* @param sftp - The SFTP wrapper.
|
|
453
|
+
* @param remotePath - The path to the remote directory.
|
|
454
|
+
* @returns A promise that resolves when the directory is created.
|
|
455
|
+
*/
|
|
456
|
+
public createRemoteDirectory(remotePath: string, mode: number = 0o755): Promise<void> {
|
|
457
|
+
return this.withSftp(async (sftp) => {
|
|
458
|
+
const directories = remotePath.split('/');
|
|
459
|
+
let currentPath = '';
|
|
460
|
+
|
|
461
|
+
for (const directory of directories) {
|
|
462
|
+
currentPath += `${directory}/`;
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await new Promise<void>((resolve, reject) => {
|
|
466
|
+
sftp.stat(currentPath, (err) => {
|
|
467
|
+
if (!err) return resolve();
|
|
468
|
+
|
|
469
|
+
sftp.mkdir(currentPath, { mode }, (err) => {
|
|
470
|
+
if (err) {
|
|
471
|
+
return reject(new Error(`ssh.createRemoteDir: err ${err}, remotePath: ${remotePath}`));
|
|
472
|
+
}
|
|
473
|
+
resolve();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
} catch (error) {
|
|
478
|
+
console.error(`Failed to create directory: ${currentPath}`, error);
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Downloads a file from the remote server to a local path via SFTP.
|
|
487
|
+
* @param remotePath - The remote file path on the server.
|
|
488
|
+
* @param localPath - The local file path to save the file.
|
|
489
|
+
* @returns A promise resolving with `true` if the file was successfully downloaded.
|
|
490
|
+
*/
|
|
491
|
+
public async downloadFile(remotePath: string, localPath: string): Promise<boolean> {
|
|
492
|
+
return this.withSftp(async (sftp) => {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
sftp.fastGet(remotePath, localPath, (err) => {
|
|
495
|
+
if (err) {
|
|
496
|
+
return reject(new Error(`ssh.downloadFile: err ${err}, remotePath: ${remotePath}, localPath: ${localPath}`));
|
|
497
|
+
}
|
|
498
|
+
resolve(true);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Closes the SSH client connection.
|
|
506
|
+
*/
|
|
507
|
+
public close(): void {
|
|
508
|
+
this.client.end();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export type SshExecResult = { stdout: string; stderr: string };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Provides helper functions to work with supervisord */
|
|
2
|
+
|
|
3
|
+
import type { MiLogger } from '@milaboratories/ts-helpers';
|
|
4
|
+
import * as plpath from './pl_paths';
|
|
5
|
+
import type { SshClient } from './ssh';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
|
|
8
|
+
export async function supervisorCtlStart(
|
|
9
|
+
sshClient: SshClient,
|
|
10
|
+
remoteHome: string, arch: string,
|
|
11
|
+
) {
|
|
12
|
+
const result = await supervisorExec(sshClient, remoteHome, arch, '--daemon');
|
|
13
|
+
|
|
14
|
+
if (result.stderr) {
|
|
15
|
+
throw new Error(`Can not run ssh Platforma ${result.stderr}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function supervisorStop(
|
|
20
|
+
sshClient: SshClient,
|
|
21
|
+
remoteHome: string, arch: string,
|
|
22
|
+
) {
|
|
23
|
+
const result = await supervisorExec(sshClient, remoteHome, arch, 'ctl shutdown');
|
|
24
|
+
|
|
25
|
+
if (result.stderr) {
|
|
26
|
+
throw new Error(`Can not stop ssh Platforma ${result.stderr}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type SupervisorStatus = {
|
|
31
|
+
platforma: boolean;
|
|
32
|
+
minio: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function supervisorStatus(
|
|
36
|
+
logger: MiLogger,
|
|
37
|
+
sshClient: SshClient,
|
|
38
|
+
remoteHome: string, arch: string,
|
|
39
|
+
): Promise<boolean> {
|
|
40
|
+
const result = await supervisorExec(sshClient, remoteHome, arch, 'ctl status');
|
|
41
|
+
|
|
42
|
+
if (result.stderr) {
|
|
43
|
+
logger.info(`supervisord ctl status: stderr occurred: ${result.stderr}, stdout: ${result.stdout}`);
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const status: SupervisorStatus = {
|
|
49
|
+
platforma: isProgramRunning(result.stdout, 'platforma'),
|
|
50
|
+
minio: isProgramRunning(result.stdout, 'minio'),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (status.platforma && status.minio) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!status.minio) {
|
|
58
|
+
logger.warn('Minio is not running on the server');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!status.platforma) {
|
|
62
|
+
logger.warn('Platforma is not running on the server');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function generateSupervisordConfig(
|
|
69
|
+
minioStorageDir: string,
|
|
70
|
+
minioEnvs: Record<string, string>,
|
|
71
|
+
supervisorRemotePort: number,
|
|
72
|
+
remoteWorkDir: string,
|
|
73
|
+
platformaConfigPath: string,
|
|
74
|
+
|
|
75
|
+
minioPath: string,
|
|
76
|
+
plPath: string,
|
|
77
|
+
) {
|
|
78
|
+
const minioEnvStr = Object.entries(minioEnvs).map(([key, value]) => `${key}="${value}"`).join(',');
|
|
79
|
+
const password = randomBytes(16).toString('hex');
|
|
80
|
+
const freePort = supervisorRemotePort;
|
|
81
|
+
|
|
82
|
+
return `
|
|
83
|
+
[supervisord]
|
|
84
|
+
logfile=${remoteWorkDir}/supervisord.log
|
|
85
|
+
loglevel=info
|
|
86
|
+
pidfile=${remoteWorkDir}/supervisord.pid
|
|
87
|
+
|
|
88
|
+
[inet_http_server]
|
|
89
|
+
port=127.0.0.1:${freePort}
|
|
90
|
+
username=default-user
|
|
91
|
+
password=${password}
|
|
92
|
+
|
|
93
|
+
[supervisorctl]
|
|
94
|
+
serverurl=http://127.0.0.1:${freePort}
|
|
95
|
+
username=default-user
|
|
96
|
+
password=${password}
|
|
97
|
+
|
|
98
|
+
[program:platforma]
|
|
99
|
+
autostart=true
|
|
100
|
+
depends_on=minio
|
|
101
|
+
command=${plPath} --config ${platformaConfigPath}
|
|
102
|
+
directory=${remoteWorkDir}
|
|
103
|
+
autorestart=true
|
|
104
|
+
|
|
105
|
+
[program:minio]
|
|
106
|
+
autostart=true
|
|
107
|
+
environment=${minioEnvStr}
|
|
108
|
+
command=${minioPath} server ${minioStorageDir}
|
|
109
|
+
directory=${remoteWorkDir}
|
|
110
|
+
autorestart=true
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function supervisorExec(
|
|
115
|
+
sshClient: SshClient,
|
|
116
|
+
remoteHome: string, arch: string,
|
|
117
|
+
command: string,
|
|
118
|
+
) {
|
|
119
|
+
const supervisorCmd = plpath.supervisorBin(remoteHome, arch);
|
|
120
|
+
const supervisorConf = plpath.supervisorConf(remoteHome);
|
|
121
|
+
|
|
122
|
+
const cmd = `${supervisorCmd} --configuration ${supervisorConf} ${command}`;
|
|
123
|
+
return await sshClient.exec(cmd);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isProgramRunning(output: string, programName: string) {
|
|
127
|
+
// eslint-disable-next-line no-control-regex
|
|
128
|
+
const stripAnsi = (str: string) => str.replace(/\x1B\[[0-9;]*m/g, '');
|
|
129
|
+
|
|
130
|
+
const cleanedOutput = stripAnsi(output);
|
|
131
|
+
|
|
132
|
+
return cleanedOutput.split('\n').some((line) => {
|
|
133
|
+
const [name, status] = line.trim().split(/\s{2,}/); // Split string by 2 spaces.
|
|
134
|
+
|
|
135
|
+
return name === programName && status === 'Running';
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const PORT = 3001;
|
|
3
|
+
const MESSAGE = "Hello, this is a simple Node.js server!";
|
|
4
|
+
const server = http.createServer((req, res) => {
|
|
5
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
6
|
+
res.end(MESSAGE);
|
|
7
|
+
});
|
|
8
|
+
server.listen(PORT, () => {
|
|
9
|
+
console.log(`Server is running on port ${PORT}`);
|
|
10
|
+
});
|