-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathApplication.ts
More file actions
646 lines (565 loc) · 22 KB
/
Application.ts
File metadata and controls
646 lines (565 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
import { getConfigObj, getConfigValue } from '../config/configUtils.js';
import { CONFIG_PARAMS } from '../utility/hdbTerms.js';
import logger from '../utility/logging/harper_logger.js';
import { dirname, extname, join } from 'node:path';
import {
access,
constants,
cp,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { createReadStream, existsSync, readdirSync } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { extract } from 'tar-fs';
import gunzip from 'gunzip-maybe';
import type { Logger } from './Logger.ts';
interface ApplicationConfig {
// define known config properties
package: string;
install?: {
command?: string;
timeout?: number;
};
// an application config can have other arbitrary properties
[key: string]: unknown;
}
export class InvalidPackageIdentifierError extends TypeError {
constructor(applicationName: string, packageIdentifier: unknown) {
super(
`Invalid 'package' property for application ${applicationName}: expected string, got ${typeof packageIdentifier}`
);
}
}
export class InvalidInstallPropertyError extends TypeError {
constructor(applicationName: string, installProperty: unknown) {
super(
`Invalid 'install' property for application ${applicationName}: expected object, got ${typeof installProperty}`
);
}
}
export class InvalidInstallCommandError extends TypeError {
constructor(applicationName: string, command: unknown) {
super(
`Invalid 'install.command' property for application ${applicationName}: expected string, got ${typeof command}`
);
}
}
export class InvalidInstallTimeoutError extends TypeError {
constructor(applicationName: string, timeout: unknown) {
super(
`Invalid 'install.timeout' property for application ${applicationName}: expected non-negative number, got ${typeof timeout}`
);
}
}
export function assertApplicationConfig(
applicationName: string,
applicationConfig: Record<'package', unknown> & Record<string, unknown>
): asserts applicationConfig is ApplicationConfig {
if (typeof applicationConfig.package !== 'string') {
throw new InvalidPackageIdentifierError(applicationName, applicationConfig.package);
}
if ('install' in applicationConfig) {
if (
typeof applicationConfig.install !== 'object' ||
applicationConfig.install === null ||
Array.isArray(applicationConfig.install)
) {
throw new InvalidInstallPropertyError(applicationName, applicationConfig.install);
}
if ('command' in applicationConfig.install && typeof applicationConfig.install.command !== 'string') {
throw new InvalidInstallCommandError(applicationName, applicationConfig.install.command);
}
if (
'timeout' in applicationConfig.install &&
(typeof applicationConfig.install.timeout !== 'number' || applicationConfig.install.timeout < 0)
) {
throw new InvalidInstallTimeoutError(applicationName, applicationConfig.install.timeout);
}
}
}
/**
* Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
*
* Only one of `application.payload` or `application.package` should be specified; otherwise, an error is thrown.
*
* Writes the application to the configured components root directory using the `application.name` and overwrites any existing directory.
*
* This method should only be called from the main thread
*/
export async function extractApplication(application: Application) {
// Can't specify neither
if (!application.payload && !application.packageIdentifier) {
throw new Error('Either payload or package must be provided');
}
// Can't specify both
if (application.payload && application.packageIdentifier) {
throw new Error('Both payload and package cannot be provided');
}
// Resolve the tarball from the input
let tarballPath: string;
let tarball: Readable;
if (application.payload) {
// Given a payload, create a Readable from the Buffer or string
tarball = Readable.from(
application.payload instanceof Buffer ? application.payload : Buffer.from(application.payload, 'base64')
);
} else {
// Given a package, there are a a couple options
const parentDirPath = dirname(application.dirPath);
// If the package identifier is a file path we need to check if its a tarball or a directory
if (application.packageIdentifier.startsWith('file:')) {
const packagePath = application.packageIdentifier.slice(5);
try {
// Have to remove the 'file:' prefix in order to use fs methods
const stats = await stat(packagePath);
if (stats.isDirectory()) {
// If its a directory, symlink
await symlink(packagePath, application.dirPath, 'dir');
// And return early since we're done; no extraction needed
return;
}
if (!stats.isFile()) {
throw new Error(`File path specified in package identifier is not a file or directory: ${packagePath}`);
}
// If its a file, we assume it can be unzipped and extracted.
// We are using maybe-gunzip to handle both gzipped and non-gzipped tarballs
// And then we are happy to let the `tar-fs` library handle the extraction.
// Maybe worth adding some detection or at least some error handling if that step below fails.
tarballPath = packagePath;
tarball = createReadStream(tarballPath);
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error(`File path specified in package identifier does not exist: ${packagePath}`);
} else {
throw err;
}
}
} else {
// Given a package, resolve using `npm pack` (downloads the package as a tarball and writes the path to stdout)
const {
stdout: tarballFilePath,
code,
stderr,
} = await nonInteractiveSpawn(application.name, 'npm', ['pack', application.packageIdentifier], parentDirPath);
if (code !== 0) throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
tarballPath = join(parentDirPath, tarballFilePath.trim());
// Create a Readable from the tarball
tarball = createReadStream(tarballPath);
}
}
// Create the application directory
try {
await access(application.dirPath, constants.F_OK);
// directory already exists; clear it
await rm(application.dirPath, { recursive: true, force: true });
} catch (err) {
// Ignore does not exist error
if (err.code !== 'ENOENT') {
throw err;
}
}
// Finally, create the application directory fresh
await mkdir(application.dirPath, { recursive: true });
// Now pipeline the tarball into maybe-gunzip then tar-fs to reliably decompress and extract the contents
await pipeline(tarball, gunzip(), extract(application.dirPath));
// If the extracted directory contains a single folder, move the contents up one level
// The `npm pack` command does this (the top-level folder is called "package")
// Other packing tools may have similar behavior, but the directory name is not guaranteed.
const extracted = await readdir(application.dirPath, { withFileTypes: true });
if (extracted.length === 1 && extracted[0].isDirectory()) {
const topLevelDirPath = join(application.dirPath, extracted[0].name);
const tempDirPath = await mkdtemp(application.dirPath);
// Copy contents of top-level directory to temp directory (in order to avoid collisions of top-level directory name and one of the contents)
await cp(topLevelDirPath, tempDirPath, { recursive: true });
// Remove top-level directory
await rm(topLevelDirPath, { recursive: true, force: true });
// Copy contents of temp directory to application directory
await cp(tempDirPath, application.dirPath, { recursive: true });
// Finally, remove the temp dir
await rm(tempDirPath, { recursive: true, force: true });
}
// Clean up the original tarball
if (tarballPath) {
await rm(tarballPath, { force: true });
}
}
/**
* Install an application to its relative `application.dirPath` using either a
* configured `application.install` command, a derived package manager from the
* application's `package.json#devEngines`, or falling back to the default
* package manager, `npm`.
*
* Will return early if `node_modules` already exists within the `application.dirPath`
*
* This method should only be called from the main thread
*/
export async function installApplication(application: Application) {
let packageJSON: any;
try {
packageJSON = JSON.parse(await readFile(join(application.dirPath, 'package.json'), 'utf8'));
} catch (err) {
if (err.code !== 'ENOENT') throw err;
// If no package.json, nothing to install
application.logger.debug(`Application ${application.name} has no package.json; skipping install`);
return;
}
try {
// Does node_modules exist?
await access(join(application.dirPath, 'node_modules'), constants.F_OK);
application.logger.debug(`Application ${application.name} already has node_modules; skipping install`);
return;
} catch (err) {
if (err.code !== 'ENOENT') throw err;
// If node_modules doesn't exist, we need to install dependencies
}
// If custom install command is specified, run it
if (application.install?.command) {
const [command, ...args] = application.install.command.split(' ');
const { stdout, stderr, code } = await nonInteractiveSpawn(
application.name,
command,
args,
application.dirPath,
application.install?.timeout
);
// if it succeeds, return
if (code === 0) {
return;
}
if (stdout) {
printStd(application.name, command, stdout, 'stdout', 'warn');
}
if (stderr) {
printStd(application.name, command, stderr, 'stderr', 'warn');
}
// and throw a descriptive error
throw new Error(
`Failed to install dependencies for ${application.name} using custom install command: ${application.install.command}. Exit code: ${code}`
);
}
// Next, try package.json devEngines field
const { packageManager } = packageJSON.devEngines || {};
// Custom package manager specified
if (packageManager) {
// On any given system we want to leverage the `name` to match the package manager executable
let onFail: string | undefined = packageManager.onFail;
const validOnFailValues = ['ignore', 'warn', 'error'];
if (onFail === 'download') {
application.logger.warn(
'Harper currently does not support `devEngines.packageManager.onFail = "download"`. Defaulting to "error"'
);
onFail = 'error';
} else if (onFail && !validOnFailValues.includes(onFail)) {
application.logger.error(
`Invalid \`devEngines.packageManager.onFail\` value: "${onFail}". Expected one of ${validOnFailValues.map((v) => `"${v}"`).join(', ')}. Defaulting to "error"`
);
onFail = 'error';
}
onFail = onFail || 'error';
// TODO: Implement a version check / resolution system
// For example, say they specify a specific major version for their package manager
// Maybe on our system, we have all of the supported majors (for a given Node.js major) of any supported package manager.
// Then we can do something like <name>@<version> for the corresponding executable.
// `devEngines: { packageManager: { name: 'pnpm', version: '>=7' } }`
// Would result in `pnpm@7` being used as the executable.
// Important note: an `npm` version should not be specifiable; the only valid npm version is the one installed alongside Node.js
const { stdout, stderr, code } = await nonInteractiveSpawn(
application.name,
(application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + packageManager.name,
['install'], // All of `npm`, `yarn`, and `pnpm` support the `install` command. If we need to configure options here we may have to use some other defaults though
application.dirPath,
application.install?.timeout
);
// if it succeeds, return
if (code === 0) {
return;
}
// Otherwise handle failure case based on `onFail` value
if (onFail === 'error') {
// Log the std outputs using the error log level (in case the user doesn't have debug level set)
if (stdout) {
printStd(application.name, packageManager.name, stdout, 'stdout', 'error');
}
if (stderr) {
printStd(application.name, packageManager.name, stderr, 'stderr', 'error');
}
// And throw an error instead of continuing
throw new Error(
`Failed to install dependencies for ${application.name} using ${packageManager.name}. Exit code: ${code}`
);
}
// If onFail is 'warn', print outputs using the warn level, plus an additional message
if (onFail === 'warn') {
if (stdout) {
printStd(application.name, packageManager.name, stdout, 'stdout', 'warn');
}
if (stderr) {
printStd(application.name, packageManager.name, stderr, 'stderr', 'warn');
}
application.logger.warn(
`Failed to install dependencies for ${application.name} using ${packageManager.name}. Exit code: ${code}`
);
}
// But then fall through to installing with npm
}
// Finally, default to running `npm install`
const { stdout, stderr, code } = await nonInteractiveSpawn(
application.name,
(application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + 'npm',
['install', '--force'],
application.dirPath
);
// if it succeeds, return
if (code === 0) {
return;
}
// Otherwise, print the stdout and stderr outputs
if (stdout) {
printStd(application.name, 'npm', stdout, 'stdout', 'warn');
}
if (stderr) {
printStd(application.name, 'npm', stderr, 'stderr', 'error');
}
// and throw a descriptive error
throw new Error(`Failed to install dependencies for ${application.name} using npm default. Exit code: ${code}`);
}
interface ApplicationOptions {
name: string;
payload?: Buffer | string;
packageIdentifier?: string;
install?: { command?: string; timeout?: number };
}
export class Application {
name: string;
payload?: Buffer | string;
packageIdentifier?: string;
install?: { command?: string; timeout?: number };
dirPath: string;
logger: Logger;
packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".
constructor({ name, payload, packageIdentifier, install }: ApplicationOptions) {
this.name = name;
this.payload = payload;
this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
this.install = install;
this.dirPath = join(getConfigValue(CONFIG_PARAMS.COMPONENTSROOT), name);
this.logger = logger.loggerWithTag(name);
this.packageManagerPrefix = getConfigValue(CONFIG_PARAMS.APPLICATIONS_PACKAGEMANAGERPREFIX);
}
}
/**
* Based on an old implementation for a method called `getPkgPrefix()` that was used
* during the installation process in order to actually resolve what the user specifies for a
* component matching some of npm's package resolution rules.
*/
export function derivePackageIdentifier(packageIdentifier: string) {
if (packageIdentifier.includes(':')) {
return packageIdentifier;
}
if (packageIdentifier.startsWith('@') || (!packageIdentifier.startsWith('@') && !packageIdentifier.includes('/'))) {
return `npm:${packageIdentifier}`;
}
if (extname(packageIdentifier) || existsSync(packageIdentifier)) {
return `file:${packageIdentifier}`;
}
return `github:${packageIdentifier}`;
}
/**
* Extract and install the specified application.
*
* This method should only be called from the main thread
*
* @param application The application to prepare.
* @returns A promise that resolves when all preparation steps complete.
*/
export function prepareApplication(application: Application) {
return extractApplication(application).then(() => installApplication(application));
}
/**
* Install all applications specified in the root config.
*
* This method should only be called from the main thread otherwise certain
* operations may conflict with each other (such as writing to the same directory).
*/
export async function installApplications() {
const applicationInstallationPromises: Promise<void>[] = [];
// first install any built-in components specified from env vars
for (const { name, packageIdentifier } of getEnvBuiltInComponents()) {
if (packageIdentifier.startsWith('@/')) {
// this is a package relative module id, so later we will resolve it, but we don't need to install anything
continue;
}
const application = new Application({
name,
packageIdentifier,
});
applicationInstallationPromises.push(prepareApplication(application));
}
const config = getConfigObj();
const componentsRootDirPath = getConfigValue(CONFIG_PARAMS.COMPONENTSROOT);
// Ensure component directory exists
await mkdir(componentsRootDirPath, { recursive: true });
const harperApplicationLockPath = join(getConfigValue(CONFIG_PARAMS.ROOTPATH), 'harper-application-lock.json');
let harperApplicationLock: { applications: Record<string, ApplicationConfig> } = { applications: {} };
try {
harperApplicationLock = JSON.parse(await readFile(harperApplicationLockPath, 'utf8'));
} catch (error) {
// Ignore file not found error; will create new lock file after installations
if (error.code !== 'ENOENT') {
throw error;
}
}
for (const [name, applicationConfig] of Object.entries(config)) {
// Pre-validation check if the configuration is actually for an application
// Don't want to throw an error here as the config may contain non-application entries
if (typeof applicationConfig !== 'object' || applicationConfig === null || !('package' in applicationConfig)) {
continue;
}
try {
// Then do proper error-based validation with TypeScript `asserts` to provide type safety
// This will throw if the config is invalid
assertApplicationConfig(name, applicationConfig);
const application = new Application({
name,
packageIdentifier: applicationConfig.package,
install: applicationConfig.install,
});
// Lock check: only install if not already installed with matching configuration
if (
existsSync(application.dirPath) &&
harperApplicationLock.applications[name] &&
JSON.stringify(harperApplicationLock.applications[name]) === JSON.stringify(applicationConfig)
) {
logger.info(`Application ${name} is already installed with matching configuration; skipping installation`);
continue;
}
applicationInstallationPromises.push(prepareApplication(application));
harperApplicationLock.applications[name] = applicationConfig;
} catch (error) {
logger.error(`Skipping installation of application ${name} due to invalid configuration: ${error.message}`);
}
}
const applicationInstallationStatuses = await Promise.allSettled(applicationInstallationPromises);
logger.debug(applicationInstallationStatuses);
logger.info('All root applications loaded');
// Finally, write the lock file
await writeFile(harperApplicationLockPath, JSON.stringify(harperApplicationLock, null, 2), 'utf8');
}
function getGitSSHCommand() {
const rootDir = getConfigValue(CONFIG_PARAMS.ROOTPATH);
const sshDir = join(rootDir, 'ssh');
if (existsSync(sshDir)) {
for (const file of readdirSync(sshDir)) {
if (file.includes('.key')) {
return `ssh -F ${join(sshDir, 'config')} -o UserKnownHostsFile=${join(sshDir, 'known_hosts')}`;
}
}
}
}
/**
* Execute a command (using `spawn`) with stdin ignored.
*
* Stdout is logged chunk-by-chunk. Stderr is buffered and then logged line-by-line.
*
* Rejects with an error if the command fails or times out.
*
* @param command The command to run.
* @param args The arguments to pass to the command.
* @param cwd The working directory for the command.
* @param timeoutMs The timeout for the command in milliseconds. Defaults to 5 minutes.
* @returns A promise that resolves when the command completes.
*/
export function nonInteractiveSpawn(
applicationName: string,
command: string,
args: string[],
cwd: string,
timeoutMs: number = 5 * 60 * 1000
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
logger
.loggerWithTag(`${applicationName}:spawn:${command}`)
.debug(`Executing \`${command} ${args.join(' ')}\` in ${cwd}`);
const env = { ...process.env };
const gitSSHCommand = getGitSSHCommand();
if (gitSSHCommand) {
env.GIT_SSH_COMMAND = gitSSHCommand;
}
const childProcess = spawn(command, args, {
shell: true,
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeout = setTimeout(() => {
childProcess.kill();
reject(new Error(`Command\`${command} ${args.join(' ')}\` timed out after ${timeoutMs}ms`));
}, timeoutMs);
let stdout = '';
childProcess.stdout.on('data', (chunk) => {
// buffer stdout for later resolve
stdout += chunk.toString();
// log stdout lines immediately
// TODO: Technically nothing guarantees that a chunk will be a complete line so need to implement
// something here to buffer until a newline character, then log the complete line
logger.loggerWithTag(`${applicationName}:spawn:${command}:stdout`).debug(chunk.toString());
});
// buffer stderr
let stderr = '';
childProcess.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
childProcess.on('error', (error) => {
clearTimeout(timeout);
// Print out stderr before rejecting
if (stderr) {
printStd(applicationName, command, stderr, 'stderr');
}
reject(error);
});
childProcess.on('close', (code) => {
clearTimeout(timeout);
if (stderr) {
printStd(applicationName, command, stderr, 'stderr');
}
logger.loggerWithTag(`${applicationName}:spawn:${command}`).debug(`Process exited with code ${code}`);
resolve({
stdout,
stderr,
code,
});
});
});
}
export function getEnvBuiltInComponents() {
const builtInComponents: { name: string; packageIdentifier: string }[] = [];
if (process.env.HARPER_BUILTIN_COMPONENTS) {
for (const componentDefinition of process.env.HARPER_BUILTIN_COMPONENTS.split(',')) {
const [name, packageIdentifier] = componentDefinition.trim().split('=');
if (!componentDefinition) continue;
builtInComponents.push({ name, packageIdentifier });
}
}
return builtInComponents;
}
function printStd(
applicationName: string,
command: string,
stdString: string,
stdStreamLabel: 'stdout' | 'stderr',
level: 'debug' | 'warn' | 'error' = 'debug'
) {
const stdLogger = logger.loggerWithTag(`${applicationName}:spawn:${command}:${stdStreamLabel}`);
for (const line of stdString.split('\n')) {
stdLogger[level](line);
}
}