301 lines
9.2 KiB
JavaScript
301 lines
9.2 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const vhostService = require('./vhostService');
|
|
|
|
const RECIPES_DIR = path.join(__dirname, '../recipes');
|
|
const INSTALLED_FILE = '/var/lib/sawa/installed.json';
|
|
|
|
// Ensure data directory exists
|
|
if (!fs.existsSync(path.dirname(INSTALLED_FILE))) {
|
|
try {
|
|
execFileSync('mkdir', ['-p', path.dirname(INSTALLED_FILE)]);
|
|
execFileSync('chown', ['root:root', path.dirname(INSTALLED_FILE)]);
|
|
} catch (e) {
|
|
console.error('Failed to prepare /var/lib/sawa:', e.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates identifiers for security (dbName, dbUser, recipeId).
|
|
*/
|
|
const validateIdentifier = (str) => {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(str)) {
|
|
throw new Error(`Invalid identifier: ${str}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validates password to prevent issues with quoting.
|
|
*/
|
|
const validatePassword = (str) => {
|
|
// We escape single quotes now, but still good to avoid some patterns if necessary.
|
|
// For now, removing the $$ restriction since we use stdin and single quote escaping.
|
|
};
|
|
|
|
/**
|
|
* Escapes single quotes for standard SQL.
|
|
*/
|
|
const escapeSql = (str) => str.replace(/'/g, "''");
|
|
|
|
/**
|
|
* Loads all recipe.json files from the recipes directory.
|
|
*/
|
|
const loadRecipes = () => {
|
|
try {
|
|
if (!fs.existsSync(RECIPES_DIR)) return [];
|
|
const folders = fs.readdirSync(RECIPES_DIR);
|
|
return folders.map(folder => {
|
|
const recipePath = path.join(RECIPES_DIR, folder, 'recipe.json');
|
|
if (fs.existsSync(recipePath)) {
|
|
return JSON.parse(fs.readFileSync(recipePath, 'utf-8'));
|
|
}
|
|
return null;
|
|
}).filter(Boolean);
|
|
} catch (err) {
|
|
console.error('Failed to load recipes:', err);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reads the status of installed applications.
|
|
*/
|
|
const getInstalledApps = () => {
|
|
try {
|
|
if (!fs.existsSync(INSTALLED_FILE)) return {};
|
|
return JSON.parse(fs.readFileSync(INSTALLED_FILE, 'utf-8'));
|
|
} catch (err) {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Executes installation for a specific recipe.
|
|
* Shared logic for both standard and streaming installs.
|
|
*/
|
|
const installApp = async (recipeId, options, onLog = null) => {
|
|
validateIdentifier(recipeId);
|
|
|
|
const logs = [];
|
|
|
|
const log = (msg) => {
|
|
const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
|
logs.push(line);
|
|
if (onLog) onLog(line);
|
|
console.log(`[Installer:${recipeId}] ${line}`);
|
|
return line;
|
|
};
|
|
|
|
try {
|
|
const recipes = loadRecipes();
|
|
const recipe = recipes.find(r => r.id === recipeId);
|
|
if (!recipe) throw new Error(`Recipe ${recipeId} not found`);
|
|
|
|
log(`Starting installation of ${recipe.name} v${recipe.version}...`);
|
|
|
|
// Clean start: remove existing installDir if any
|
|
if (recipe.installDir) {
|
|
log(`Cleaning up existing directory: ${recipe.installDir}...`);
|
|
try { execFileSync('rm', ['-rf', recipe.installDir]); } catch (e) { }
|
|
}
|
|
|
|
// 0. Dependencies
|
|
if (recipe.dependencies) {
|
|
await _stepDependencies(recipe, log);
|
|
}
|
|
|
|
// 1. Fetch
|
|
await _stepFetch(recipe, log);
|
|
|
|
// 2. Database
|
|
if (recipe.database) {
|
|
validateIdentifier(recipe.database.dbName);
|
|
validateIdentifier(recipe.database.dbUser);
|
|
await _stepDatabase(recipe.database, options.dbPassword, log);
|
|
}
|
|
|
|
// 3. Custom Steps
|
|
if (recipe.steps) {
|
|
await _stepCustom(recipe, options, options.dbPassword, log);
|
|
}
|
|
|
|
// 4. Service (PM2)
|
|
if (recipe.service) {
|
|
await _stepService(recipe.service, recipe.installDir, log);
|
|
}
|
|
|
|
// 5. VHost
|
|
if (recipe.vhost) {
|
|
await _stepVHost(recipe, options, log);
|
|
}
|
|
|
|
// Finalize
|
|
const installed = getInstalledApps();
|
|
installed[recipeId] = {
|
|
version: recipe.version,
|
|
installedAt: new Date().toISOString(),
|
|
domain: options.domain
|
|
};
|
|
fs.writeFileSync(INSTALLED_FILE, JSON.stringify(installed, null, 2));
|
|
|
|
log(`Successfully installed ${recipe.name}!`);
|
|
return { success: true, logs };
|
|
|
|
} catch (err) {
|
|
log(`FATAL ERROR: ${err.message}`);
|
|
return { success: false, logs };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* SSE-friendly wrapper for installation.
|
|
*/
|
|
const streamInstall = async (recipeId, options, onLog) => {
|
|
return await installApp(recipeId, options, onLog);
|
|
};
|
|
|
|
// --- PRIVATE STEP HELPERS ---
|
|
|
|
const _stepDependencies = async (recipe, log) => {
|
|
const { dependencies } = recipe;
|
|
if (!dependencies || !Array.isArray(dependencies)) return;
|
|
|
|
log(`Installing dependencies: ${dependencies.join(', ')}...`);
|
|
for (const item of dependencies) {
|
|
log(`Adding package: ${item}...`);
|
|
execFileSync('apk', ['add', item]);
|
|
}
|
|
log('Dependencies installed.');
|
|
};
|
|
|
|
const _stepFetch = async (recipe, log) => {
|
|
const { fetch, version, installDir } = recipe;
|
|
log(`Preparing directory: ${installDir}...`);
|
|
|
|
// Panel runs as root, no sudo needed
|
|
execFileSync('mkdir', ['-p', installDir]);
|
|
execFileSync('chown', ['root:root', installDir]);
|
|
|
|
if (fetch.type === 'none') {
|
|
log('No fetch required, skipping...');
|
|
return;
|
|
}
|
|
|
|
if (fetch.type === 'binary') {
|
|
const url = fetch.url.replace(/{version}/g, version);
|
|
const dest = path.join(installDir, fetch.filename);
|
|
log(`Downloading binary...`);
|
|
execFileSync('curl', ['-L', '-o', dest, url]);
|
|
if (fetch.chmod) {
|
|
execFileSync('chmod', [fetch.chmod, dest]);
|
|
}
|
|
}
|
|
log('Fetch completed.');
|
|
};
|
|
|
|
const _stepDatabase = async (dbConfig, password, log) => {
|
|
const { type, dbName, dbUser } = dbConfig;
|
|
log(`Provisioning ${type} database: ${dbName}...`);
|
|
|
|
if (type === 'postgres') {
|
|
const escapedPassword = escapeSql(password);
|
|
|
|
// Idempotent Cleanup
|
|
const cleanupSql =
|
|
`DROP DATABASE IF EXISTS ${dbName};\n` +
|
|
`DROP USER IF EXISTS ${dbUser};\n`;
|
|
|
|
const createSql =
|
|
`CREATE USER ${dbUser} WITH PASSWORD '${escapedPassword}';\n` +
|
|
`CREATE DATABASE ${dbName} OWNER ${dbUser};\n`;
|
|
|
|
try {
|
|
log(`Cleaning up existing database/user if any...`);
|
|
execFileSync('sudo', ['-u', 'postgres', 'psql'], {
|
|
input: cleanupSql,
|
|
env: { ...process.env }
|
|
});
|
|
|
|
log(`Creating database and user...`);
|
|
execFileSync('sudo', ['-u', 'postgres', 'psql'], {
|
|
input: createSql,
|
|
env: { ...process.env }
|
|
});
|
|
} catch (e) {
|
|
log(`Database note: ${e.message.split('\n')[0]}`);
|
|
}
|
|
} else if (type === 'mariadb') {
|
|
// MariaDB idempotency
|
|
execFileSync('mysql', ['-e', `DROP DATABASE IF EXISTS ${dbName};`]);
|
|
execFileSync('mysql', ['-e', `DROP USER IF EXISTS '${dbUser}'@'localhost';`]);
|
|
|
|
execFileSync('mysql', ['-e', `CREATE DATABASE ${dbName};`]);
|
|
execFileSync('mysql', ['-e', `CREATE USER '${dbUser}'@'localhost' IDENTIFIED BY '${password}';`]);
|
|
execFileSync('mysql', ['-e', `GRANT ALL PRIVILEGES ON ${dbName}.* TO '${dbUser}'@'localhost';`]);
|
|
execFileSync('mysql', ['-e', 'FLUSH PRIVILEGES;']);
|
|
}
|
|
log('Database provisioning completed.');
|
|
};
|
|
|
|
const _stepCustom = async (recipe, options, password, log) => {
|
|
const { steps, installDir } = recipe;
|
|
for (const step of steps) {
|
|
log(`Executing: ${step.name}...`);
|
|
execFileSync('sh', ['-c', step.cmd], {
|
|
cwd: installDir,
|
|
env: {
|
|
...process.env,
|
|
SAWA_DB_PASSWORD: password,
|
|
SAWA_DOMAIN: options.domain,
|
|
SAWA_RECIPE_DIR: path.join(RECIPES_DIR, recipe.id)
|
|
}
|
|
});
|
|
}
|
|
log('Custom steps completed.');
|
|
};
|
|
|
|
const _stepService = async (serviceConfig, installDir, log) => {
|
|
const { type, name, command, user } = serviceConfig;
|
|
log(`Registering ${type} service: ${name}...`);
|
|
|
|
if (type === 'pm2') {
|
|
try { execFileSync('pm2', ['delete', name]); } catch (e) { }
|
|
|
|
const args = ['start', command, '--name', name];
|
|
if (user) {
|
|
args.push('--user', user);
|
|
}
|
|
|
|
execFileSync('pm2', args, { cwd: installDir });
|
|
execFileSync('pm2', ['save']);
|
|
}
|
|
log('Service registration completed.');
|
|
};
|
|
|
|
const _stepVHost = async (recipe, options, log) => {
|
|
const { vhost, installDir } = recipe;
|
|
const { domain } = options;
|
|
const port = options.port || vhost.proxyPort || 80;
|
|
|
|
log(`Configuring Nginx vhost for ${domain}...`);
|
|
|
|
const type = vhost.type === 'proxy' ? 'proxy' : 'static';
|
|
const rootOrUrl = type === 'proxy' ? `http://127.0.0.1:${port}` : installDir;
|
|
|
|
// createVHost returns filename like "domain.conf.disabled"
|
|
const filename = await vhostService.createVHost(domain, rootOrUrl, port, type);
|
|
|
|
// Enable and reload
|
|
await vhostService.toggleVHost(filename, true);
|
|
await vhostService.reloadNginx();
|
|
|
|
log('Nginx vhost configured, enabled and reloaded.');
|
|
};
|
|
|
|
module.exports = {
|
|
loadRecipes,
|
|
getInstalledApps,
|
|
installApp,
|
|
streamInstall
|
|
};
|