Files
puter/src/backend/src/boot/RuntimeEnvironment.js
T

394 lines
13 KiB
JavaScript

/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require("@heyputer/putility");
const { quot } = require('@heyputer/putility').libs.string;
const { TechnicalError } = require("../errors/TechnicalError");
const { print_error_help } = require("../errors/error_help_details");
const default_config = require("./default_config");
const config = require("../config");
const { ConfigLoader } = require("../config/ConfigLoader");
// highlights a string
const hl = s => `\x1b[33;1m${s}\x1b[0m`;
// Save the original working directory
const original_cwd = process.cwd();
// === [ Puter Runtime Environment ] ===
// This file contains the RuntimeEnvironment class which is
// responsible for locating the configuration and runtime
// directories for the Puter Kernel.
// Depending on which path we're checking for configuration
// or runtime from config_paths, there will be different
// requirements. These are all possible requirements.
//
// Each check may result in the following:
// - false: this is not the desired path; skip it
// - true: this is the desired path, and it's valid
// - throw: this is the desired path, but it's invalid
const path_checks = ({ logger }) => ({ fs, path_ }) => ({
require_if_not_undefined: ({ path }) => {
if ( path == undefined ) return false;
const exists = fs.existsSync(path);
if ( !exists ) {
throw new Error(`Path does not exist: ${path}`);
}
return true;
},
skip_if_not_exists: ({ path }) => {
const exists = fs.existsSync(path);
return exists;
},
skip_if_not_in_repo: ({ path }) => {
const exists = fs.existsSync(path_.join(path, '../../.is_puter_repository'));
return exists;
},
require_read_permission: ({ path }) => {
try {
fs.readdirSync(path);
} catch (e) {
throw new Error(`Cannot readdir on path: ${path}`);
}
return true;
},
require_write_permission: ({ path }) => {
try {
fs.writeFileSync(path_.join(path, '.tmp_test_write_permission'), 'test');
fs.unlinkSync(path_.join(path, '.tmp_test_write_permission'));
} catch (e) {
throw new Error(`Cannot write to path: ${path}`);
}
return true;
},
contains_config_file: ({ path }) => {
const valid_config_names = [
'config.json',
'config.json5',
];
for ( const name of valid_config_names ) {
const exists = fs.existsSync(path_.join(path, name));
if ( exists ) {
return true;
}
}
throw new Error(`No valid config file found in path: ${path}`);
},
env_not_set: name => () => {
return ! process.env[name];
}
});
// Configuration paths in order of precedence.
// We will load configuration from the first path that's suitable.
const config_paths = ({ path_checks }) => ({ path_ }) => [
{
label: '$CONFIG_PATH',
get path () { return process.env.CONFIG_PATH },
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/etc/puter',
checks: [ path_checks.skip_if_not_exists ],
},
{
get path () {
return path_.join(original_cwd, 'volatile/config');
},
checks: [ path_checks.skip_if_not_in_repo ],
},
{
get path () {
return path_.join(original_cwd, 'config');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
const valid_config_names = [
'config.json',
'config.json5',
];
// Suitable working directories in order of precedence.
// We will `process.chdir` to the first path that's suitable.
const runtime_paths = ({ path_checks }) => ({ path_ }) => [
{
label: '$RUNTIME_PATH',
get path () { return process.env.RUNTIME_PATH },
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/var/puter',
checks: [
path_checks.skip_if_not_exists,
path_checks.env_not_set('NO_VAR_RUNTIME'),
],
},
{
get path () {
return path_.join(original_cwd, 'volatile/runtime');
},
checks: [ path_checks.skip_if_not_in_repo ],
},
{
get path () {
return path_.join(original_cwd, 'runtime');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
// Suitable mod paths in order of precedence.
const mod_paths = ({ path_checks, entry_path }) => ({ path_ }) => [
{
label: '$MOD_PATH',
get path () { return process.env.MOD_PATH },
checks: [
path_checks.require_if_not_undefined,
],
},
{
path: '/var/puter/mods',
checks: [
path_checks.skip_if_not_exists,
path_checks.env_not_set('NO_VAR_MODS'),
],
},
{
get path () {
return path_.join(path_.dirname(
entry_path || require.main.filename), '../mods');
},
checks: [ path_checks.skip_if_not_exists ],
},
];
class RuntimeEnvironment extends AdvancedBase {
static MODULES = {
fs: require('node:fs'),
path_: require('node:path'),
crypto: require('node:crypto'),
format: require('string-template'),
}
constructor ({ logger, entry_path, boot_parameters }) {
super();
this.logger = logger;
this.entry_path = entry_path;
this.boot_parameters = boot_parameters;
this.path_checks = path_checks(this)(this.modules);
this.config_paths = config_paths(this)(this.modules);
this.runtime_paths = runtime_paths(this)(this.modules);
this.mod_paths = mod_paths(this)(this.modules);
}
init () {
try {
return this.init_();
} catch (e) {
this.logger.error(e);
print_error_help(e);
process.exit(1);
}
}
init_ () {
// This variable, called "environment", will be passed back to Kernel
// with some helpful values. A partial-population of this object later
// in this function will be used when evaluating configured paths.
const environment = {};
environment.source = this.modules.path_.dirname(
this.entry_path || require.main.filename);
const config_path_entry = this.get_first_suitable_path_(
{ pathFor: 'configuration' },
this.config_paths,
[
this.path_checks.require_read_permission,
// this.path_checks.contains_config_file,
]
);
// Note: there used to be a 'mods_path_entry' here too
// but it was never used
const pwd_path_entry = this.get_first_suitable_path_(
{ pathFor: 'working directory' },
this.runtime_paths,
[ this.path_checks.require_write_permission ]
);
process.chdir(pwd_path_entry.path);
// Check for a valid config file in the config path
let using_config;
for ( const name of valid_config_names ) {
const exists = this.modules.fs.existsSync(
this.modules.path_.join(config_path_entry.path, name)
);
if ( exists ) {
using_config = name;
break;
}
}
const owrite_config = this.boot_parameters.args.overwriteConfig;
const { fs, path_, crypto } = this.modules;
if ( !using_config || owrite_config ) {
const generated_values = {};
generated_values.cookie_name = crypto.randomUUID();
generated_values.jwt_secret = crypto.randomUUID();
generated_values.url_signature_secret = crypto.randomUUID();
generated_values.private_uid_secret = crypto.randomBytes(24).toString('hex');
generated_values.private_uid_namespace = crypto.randomUUID();
if ( using_config ) {
this.logger.info(
`Overwriting ${quot(using_config)} because ` +
`${hl('--overwrite-config')} is set`
);
// make backup
fs.copyFileSync(
path_.join(config_path_entry.path, using_config),
path_.join(config_path_entry.path, using_config + '.bak'),
);
// preserve generated values
{
const config_raw = fs.readFileSync(
path_.join(config_path_entry.path, using_config),
'utf8',
);
const config_values = JSON.parse(config_raw);
for ( const k in generated_values ) {
if ( ! config_values[k] ) continue;
generated_values[k] = config_values[k];
}
}
}
const generated_config = {
...default_config,
...generated_values,
};
generated_config[""] = null; // for trailing comma
fs.writeFileSync(
path_.join(config_path_entry.path, 'config.json'),
JSON.stringify(generated_config, null, 4) + '\n',
);
using_config = 'config.json';
}
let config_to_load = 'config.json';
if ( process.env.PUTER_CONFIG_PROFILE ) {
this.logger.info(
hl('PROFILE') + ' ' +
quot(process.env.PUTER_CONFIG_PROFILE) + ' ' +
`because $PUTER_CONFIG_PROFILE is set`
);
config_to_load = `${process.env.PUTER_CONFIG_PROFILE}.json`
const exists = fs.existsSync(
path_.join(config_path_entry.path, config_to_load)
);
if ( ! exists ) {
fs.writeFileSync(
path_.join(config_path_entry.path, config_to_load),
JSON.stringify({
config_name: process.env.PUTER_CONFIG_PROFILE,
$imports: ['config.json'],
}, null, 4) + '\n',
);
}
}
const loader = new ConfigLoader(this.logger, config_path_entry.path, config);
loader.enable(config_to_load);
if ( ! config.config_name ) {
throw new Error('config_name is required');
}
this.logger.info(hl(`config name`) + ` ${quot(config.config_name)}`);
const mod_paths = [];
environment.mod_paths = mod_paths;
// Trying this as a default for now...
if ( ! config.mod_directories ) {
config.mod_directories = [
'{source}/../mods/mods_enabled',
'{source}/../extensions',
];
}
// If configured, add a user-specified mod path
if ( config.mod_directories ) {
for ( const dir of config.mod_directories ) {
const mods_directory = this.modules.format(
dir, environment,
);
mod_paths.push(mods_directory);
}
}
return environment;
}
get_first_suitable_path_ (meta, paths, last_checks) {
for ( const entry of paths ) {
const checks = [...(entry.checks ?? []), ...last_checks];
this.logger.info(
`Checking path ${quot(entry.label ?? entry.path)} for ${meta.pathFor}...`
);
let checks_pass = true;
for ( const check of checks ) {
this.logger.info(
`-> doing ${quot(check.name)} on path ${quot(entry.path)}...`
);
const result = check(entry);
if ( result === false ) {
this.logger.info(
`-> ${quot(check.name)} doesn't like this path`
);
checks_pass = false;
break;
}
}
if ( ! checks_pass ) continue;
this.logger.info(
`${hl('USING')} ${quot(entry.path)} for ${meta.pathFor}.`
)
return entry;
}
if ( meta.optional ) return;
throw new TechnicalError(`No suitable path found for ${meta.pathFor}.`);
}
}
module.exports = {
RuntimeEnvironment,
};