Files
cloudron-box/src/tasks.js
T

288 lines
10 KiB
JavaScript
Raw Normal View History

2018-11-16 11:13:03 -08:00
'use strict';
exports = module.exports = {
2021-02-01 14:07:23 -08:00
get,
add,
update,
setCompleted,
setCompletedByType,
listByTypePaged,
2018-11-16 11:13:03 -08:00
2021-02-01 14:07:23 -08:00
getLogs,
2018-12-08 21:31:55 -08:00
2021-02-01 14:07:23 -08:00
startTask,
stopTask,
stopAllTasks,
2018-11-16 11:13:03 -08:00
2021-02-01 14:07:23 -08:00
removePrivateFields,
2019-12-06 08:40:16 -08:00
2021-07-12 23:35:30 -07:00
_del: del,
// task types. if you add a task here, fill up the function table in taskworker and dashboard constants.js
// '_' prefix is removed for lookup
2019-08-26 15:55:57 -07:00
TASK_APP: 'app',
2025-07-24 19:02:02 +02:00
// "prefix" allows us to locate the tasks of a specific app or backup target
2025-07-18 10:56:52 +02:00
TASK_APP_BACKUP_PREFIX: 'appBackup_',
2025-07-24 19:02:02 +02:00
TASK_FULL_BACKUP_PREFIX: 'backup_', // full backup
TASK_CLEAN_BACKUPS_PREFIX: 'cleanBackups_',
TASK_BOX_UPDATE: 'boxUpdate',
TASK_CHECK_CERTS: 'checkCerts',
2023-07-08 19:48:12 +05:30
TASK_SYNC_DYNDNS: 'syncDyndns',
2023-08-14 09:40:31 +05:30
TASK_PREPARE_DASHBOARD_LOCATION: 'prepareDashboardLocation',
2019-08-29 17:19:51 +02:00
TASK_SYNC_EXTERNAL_LDAP: 'syncExternalLdap',
2020-08-15 23:17:47 -07:00
TASK_CHANGE_MAIL_LOCATION: 'changeMailLocation',
2021-02-24 18:42:39 -08:00
TASK_SYNC_DNS_RECORDS: 'syncDnsRecords',
2018-12-10 21:05:46 -08:00
2019-08-30 13:46:55 -07:00
// error codes
ESTOPPED: 'stopped',
ECRASHED: 'crashed',
2019-10-11 19:30:21 -07:00
ETIMEOUT: 'timeout',
2019-08-30 13:46:55 -07:00
2018-12-10 21:05:46 -08:00
// testing
2025-07-18 20:55:46 +02:00
_TASK_IDENTITY: 'identity',
_TASK_CRASH: 'crash',
_TASK_ERROR: 'error',
_TASK_SLEEP: 'sleep'
2018-11-16 11:13:03 -08:00
};
const assert = require('assert'),
2019-10-22 20:12:44 -07:00
BoxError = require('./boxerror.js'),
2021-07-12 23:35:30 -07:00
database = require('./database.js'),
2018-11-16 11:13:03 -08:00
debug = require('debug')('box:tasks'),
2023-03-27 10:38:09 +02:00
logs = require('./logs.js'),
2020-08-06 14:36:25 -07:00
path = require('path'),
2018-11-29 16:13:01 -08:00
paths = require('./paths.js'),
2021-07-12 23:35:30 -07:00
safe = require('safetydance'),
2024-10-14 19:10:31 +02:00
shell = require('./shell.js')('tasks'),
2025-02-13 14:03:25 +01:00
_ = require('./underscore.js');
2018-11-16 11:13:03 -08:00
2025-07-17 00:36:11 +02:00
let gTasks = {}; // holds AbortControllers indexed by task id
2018-11-29 16:13:01 -08:00
2020-08-06 14:36:25 -07:00
const START_TASK_CMD = path.join(__dirname, 'scripts/starttask.sh');
const STOP_TASK_CMD = path.join(__dirname, 'scripts/stoptask.sh');
2019-08-30 13:46:55 -07:00
2025-07-16 15:22:00 +02:00
const TASKS_FIELDS = [ 'id', 'type', 'argsJson', 'percent', 'pending', 'completed', 'message', 'errorJson', 'creationTime', 'resultJson', 'ts' ];
2021-07-12 23:35:30 -07:00
function postProcess(task) {
assert.strictEqual(typeof task, 'object');
assert(task.argsJson === null || typeof task.argsJson === 'string');
task.args = safe.JSON.parse(task.argsJson) || [];
delete task.argsJson;
task.id = String(task.id);
2025-07-16 15:22:00 +02:00
task.pending = !!task.pending;
task.completed = !!task.completed;
2021-07-12 23:35:30 -07:00
task.result = JSON.parse(task.resultJson);
delete task.resultJson;
task.error = safe.JSON.parse(task.errorJson);
delete task.errorJson;
2025-07-16 15:22:00 +02:00
// result.pending - task is scheduled to run at some point
// result.completed - task finished and exit/crash was cleanly collected. internal flag.
task.running = !!gTasks[task.id]; // running means actively running
task.active = task.running || task.pending; // active mean task is 'done'. at this point, clients can stop polling this task.
task.success = task.completed && !task.error; // if task has completed without an error
2019-08-30 13:46:55 -07:00
// the error in db will be empty if task is done but the completed flag is not set
if (!task.active && !task.completed) {
task.error = { message: 'Task was stopped because the server restarted or crashed', code: exports.ECRASHED };
2019-08-30 13:46:55 -07:00
}
2021-07-12 23:35:30 -07:00
return task;
2019-08-27 22:39:59 -07:00
}
2021-07-12 23:35:30 -07:00
async function get(id) {
2018-11-16 11:13:03 -08:00
assert.strictEqual(typeof id, 'string');
2021-07-12 23:35:30 -07:00
const result = await database.query(`SELECT ${TASKS_FIELDS} FROM tasks WHERE id = ?`, [ id ]);
if (result.length === 0) return null;
2018-11-16 11:13:03 -08:00
return postProcess(result[0]);
2018-11-16 11:13:03 -08:00
}
2021-07-12 23:35:30 -07:00
async function update(id, task) {
2018-11-29 15:16:31 -08:00
assert.strictEqual(typeof id, 'string');
2018-12-08 18:50:06 -08:00
assert.strictEqual(typeof task, 'object');
2018-11-29 15:16:31 -08:00
2025-06-20 22:16:05 +02:00
debug(`updating task ${id} with: ${JSON.stringify(task)}`);
2018-12-08 18:50:06 -08:00
2024-06-03 19:18:36 +02:00
const args = [], fields = [];
for (const k in task) {
2021-07-12 23:35:30 -07:00
if (k === 'result' || k === 'error') {
fields.push(`${k}Json = ?`);
args.push(JSON.stringify(task[k]));
} else {
fields.push(k + ' = ?');
args.push(task[k]);
}
}
args.push(id);
2018-12-08 18:50:06 -08:00
2021-07-12 23:35:30 -07:00
const result = await database.query('UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?', args);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Task not found');
2018-11-29 15:16:31 -08:00
}
2021-07-12 23:35:30 -07:00
async function setCompleted(id, task) {
2019-09-05 11:29:46 -07:00
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof task, 'object');
debug(`setCompleted - ${id}: ${JSON.stringify(task)}`);
2025-07-16 15:22:00 +02:00
await update(id, Object.assign({ completed: true }, task));
2019-09-05 11:29:46 -07:00
}
2021-07-12 23:35:30 -07:00
async function setCompletedByType(type, task) {
2019-09-05 11:29:46 -07:00
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof task, 'object');
2021-07-12 23:35:30 -07:00
const results = await listByTypePaged(type, 1, 1);
if (results.length !== 1) throw new BoxError(BoxError.NOT_FOUND, 'No such task');
2019-09-05 11:29:46 -07:00
2021-07-12 23:35:30 -07:00
await setCompleted(results[0].id, task);
2019-09-05 11:29:46 -07:00
}
2021-07-12 23:35:30 -07:00
async function add(type, args) {
2018-12-08 18:50:06 -08:00
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(args));
2018-11-16 11:13:03 -08:00
2025-06-17 15:50:48 +02:00
const result = await database.query('INSERT INTO tasks (type, argsJson, percent, message, pending) VALUES (?, ?, ?, ?, ?)', [ type, JSON.stringify(args), 0, 'Queued', true ]);
2021-07-12 23:35:30 -07:00
return String(result.insertId);
2019-08-27 22:39:59 -07:00
}
2019-08-27 11:38:12 -07:00
2025-06-17 18:54:12 +02:00
async function startTask(id, options) {
2020-08-06 14:36:25 -07:00
assert.strictEqual(typeof id, 'string');
2019-08-27 22:39:59 -07:00
assert.strictEqual(typeof options, 'object');
2018-11-29 16:13:01 -08:00
2020-08-06 14:36:25 -07:00
const logFile = options.logFile || `${paths.TASKS_LOG_DIR}/${id}.log`;
2021-06-16 14:21:19 -07:00
debug(`startTask - starting task ${id} with options ${JSON.stringify(options)}. logs at ${logFile}`);
2018-11-29 16:13:01 -08:00
2025-07-17 01:51:04 +02:00
const ac = new AbortController();
gTasks[id] = ac;
2025-07-17 09:50:43 +02:00
const sudoOptions = {
preserveEnv: true,
encoding: 'utf8',
2025-07-17 09:50:43 +02:00
abortSignal: ac.signal,
timeout: options.timeout || 0,
onTimeout: async () => { // custom stop because kill won't do. the task is running in some other process tree
debug(`onTimeout: ${id}`);
await stopTask(id);
}
2025-07-17 09:50:43 +02:00
};
2025-07-17 01:51:04 +02:00
safe(update(id, { pending: false }), { debug }); // background. we have to create the cp immediately to prevent race with stopTask()
2025-07-17 01:51:04 +02:00
const [sudoError] = await safe(shell.sudo([ START_TASK_CMD, id, logFile, options.nice || 0, options.memoryLimit || 400, options.oomScoreAdjust || 0 ], sudoOptions));
2025-07-17 01:51:04 +02:00
if (!gTasks[id]) { // when box code is shutting down, don't update the task status as "crashed". see stopAllTasks()
debug(`startTask: ${id} completed as a result of box shutdown`);
return null;
}
2025-07-17 01:51:04 +02:00
delete gTasks[id];
2025-07-17 01:51:04 +02:00
const task = await get(id);
if (!task) return null; // task disappeared on us. this can happen when db got cleared in tests
2025-07-17 01:51:04 +02:00
if (task.completed) { // task completed. we can trust the db result
debug(`startTask: ${id} completed. error: %o`, task.error);
if (task.error) throw task.error;
return task.result;
2025-07-17 01:51:04 +02:00
}
2025-07-18 20:55:46 +02:00
assert.ok(sudoError, 'sudo should have errored because task did not complete!');
// taskworker.sh forwards the exit code of the actual worker. It's either a raw signal number OR the exit code
let taskError = null;
if (sudoError.timedOut) taskError = { message: `Task ${id} timed out`, code: exports.ETIMEOUT };
else if (sudoError.code === 70) taskError = { message: `Task ${id} stopped`, code: exports.ESTOPPED }; // set by taskworker SIGTERM
else if (sudoError.code === 9 /* SIGKILL */) taskError = { message: `Task ${id} ran out of memory or terminated`, code: exports.ECRASHED }; // SIGTERM with oom gets set as 2 by nodejs
else if (sudoError.code === 50) taskError = { message:`Task ${id} crashed with code ${sudoError.code}`, code: exports.ECRASHED };
else taskError = { message:`Task ${id} crashed with unknown code ${sudoError.code}`, code: exports.ECRASHED };
2025-07-17 01:51:04 +02:00
debug(`startTask: ${id} done. error: %o`, taskError);
throw taskError;
2018-11-29 16:13:01 -08:00
}
2021-07-12 23:35:30 -07:00
async function stopTask(id) {
2018-11-29 16:13:01 -08:00
assert.strictEqual(typeof id, 'string');
2021-07-12 23:35:30 -07:00
if (!gTasks[id]) throw new BoxError(BoxError.BAD_STATE, 'task is not active');
2018-11-29 16:13:01 -08:00
debug(`stopTask: stopping task ${id}`);
await shell.sudo([ STOP_TASK_CMD, id, ], {}); // note: this is stopping the systemd-run task. the sudo will exit when this exits
2018-11-16 11:13:03 -08:00
}
2018-12-08 20:12:23 -08:00
2021-07-12 23:35:30 -07:00
async function stopAllTasks() {
2025-07-17 01:51:04 +02:00
const acs = Object.values(gTasks);
2025-07-17 02:04:50 +02:00
debug(`stopAllTasks: ${acs.length} tasks are running. sending abort signal`);
2020-08-06 22:04:46 -07:00
gTasks = {}; // this signals startTask() to not set completion status as "crashed"
2025-07-17 01:51:04 +02:00
acs.forEach(ac => ac.abort()); // cleanup all the sudos and systemd-run
const [error] = await safe(shell.sudo([ STOP_TASK_CMD, 'all' ], { cwd: paths.baseDir() }));
2021-07-15 09:50:11 -07:00
if (error) debug(`stopAllTasks: error stopping stasks: ${error.message}`);
2019-08-28 15:00:55 -07:00
}
2021-07-12 23:35:30 -07:00
async function listByTypePaged(type, page, perPage) {
2018-12-08 20:12:23 -08:00
assert(typeof type === 'string' || type === null);
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
2024-06-03 19:18:36 +02:00
const data = [];
2021-07-12 23:35:30 -07:00
let query = `SELECT ${TASKS_FIELDS} FROM tasks`;
2018-12-08 20:12:23 -08:00
2021-07-12 23:35:30 -07:00
if (type) {
query += ' WHERE TYPE=?';
data.push(type);
}
2018-12-11 16:10:38 -08:00
2021-07-12 23:35:30 -07:00
query += ' ORDER BY creationTime DESC, id DESC LIMIT ?,?'; // put latest task first
data.push((page-1)*perPage);
data.push(perPage);
const results = await database.query(query, data);
results.forEach(postProcess);
return results;
2018-12-08 20:12:23 -08:00
}
2018-12-08 21:31:55 -08:00
2023-05-15 09:50:39 +02:00
async function getLogs(task, options) {
assert.strictEqual(typeof task, 'object');
2018-12-08 21:31:55 -08:00
assert(options && typeof options === 'object');
2019-01-08 12:10:53 -08:00
assert.strictEqual(typeof options.lines, 'number');
assert.strictEqual(typeof options.format, 'string');
assert.strictEqual(typeof options.follow, 'boolean');
2023-05-15 09:50:39 +02:00
const logFile = `${paths.TASKS_LOG_DIR}/${task.id}.log`;
if (!task.active && !safe.fs.existsSync(logFile)) throw new BoxError(BoxError.FS_ERROR, 'Log file removed/missing'); // logrotated
2018-12-08 21:31:55 -08:00
2023-05-15 09:50:39 +02:00
const cp = logs.tail([`${paths.TASKS_LOG_DIR}/${task.id}.log`], { lines: options.lines, follow: options.follow });
const logStream = new logs.LogStream({ format: options.format || 'json', source: task.id });
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
2018-12-08 21:31:55 -08:00
2022-11-06 13:44:47 +01:00
cp.stdout.pipe(logStream);
2018-12-08 21:31:55 -08:00
2022-11-06 13:44:47 +01:00
return logStream;
2018-12-08 21:31:55 -08:00
}
2019-12-06 08:40:16 -08:00
// removes all fields that are strictly private and should never be returned by API calls
function removePrivateFields(task) {
return _.pick(task, ['id', 'type', 'percent', 'message', 'error', 'running', 'active', 'creationTime', 'result', 'ts', 'success']);
2019-12-06 08:40:16 -08:00
}
2021-07-12 23:35:30 -07:00
async function del(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('DELETE FROM tasks WHERE id = ?', [ id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Task not found');
}