2018-11-16 11:13:03 -08:00
'use strict' ;
2025-10-08 20:11:55 +02:00
exports = module . exports = {
get ,
add ,
update ,
setCompleted ,
setCompletedByType ,
list ,
getLogs ,
startTask ,
stopTask ,
stopAllTasks ,
removePrivateFields ,
_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
TASK _APP : 'app' ,
// "prefix" allows us to locate the tasks of a specific app or backup site
TASK _APP _BACKUP _PREFIX : 'appBackup_' ,
TASK _FULL _BACKUP _PREFIX : 'backup_' , // full backup
TASK _CLEAN _BACKUPS _PREFIX : 'cleanBackups_' ,
TASK _BOX _UPDATE : 'boxUpdate' ,
TASK _CHECK _CERTS : 'checkCerts' ,
TASK _SYNC _DYNDNS : 'syncDyndns' ,
TASK _PREPARE _DASHBOARD _LOCATION : 'prepareDashboardLocation' ,
TASK _SYNC _EXTERNAL _LDAP : 'syncExternalLdap' ,
TASK _CHANGE _MAIL _LOCATION : 'changeMailLocation' ,
TASK _SYNC _DNS _RECORDS : 'syncDnsRecords' ,
TASK _CHECK _BACKUP _INTEGRITY : 'checkBackupIntegrity' ,
// error codes
ESTOPPED : 'stopped' ,
ECRASHED : 'crashed' ,
ETIMEOUT : 'timeout' ,
// testing
_TASK _IDENTITY : 'identity' ,
_TASK _CRASH : 'crash' ,
_TASK _ERROR : 'error' ,
_TASK _SLEEP : 'sleep'
} ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node: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' ) ,
2025-10-06 19:32:06 +02:00
mysql = require ( 'mysql2' ) ,
2025-08-14 11:17:38 +05:30
path = require ( 'node: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
2025-07-17 09:53:29 +02:00
// result.completed - task finished and exit/crash was cleanly collected. internal flag.
task . running = ! ! gTasks [ task . id ] ; // running means actively running
2025-07-18 19:33:34 +02:00
task . active = task . running || task . pending ; // active mean task is 'done'. at this point, clients can stop polling this task.
2025-07-17 09:53:29 +02:00
task . success = task . completed && ! task . error ; // if task has completed without an error
2019-08-30 13:46:55 -07:00
2025-07-18 19:33:34 +02:00
// the error in db will be empty if task is done but the completed flag is not set
if ( ! task . active && ! task . completed ) {
2025-07-17 09:53:29 +02:00
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
2025-07-17 09:53:29 +02: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
2025-07-17 09:53:29 +02: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
}
2025-10-08 11:06:24 +02:00
async function list ( page , perPage , options ) {
assert . strictEqual ( typeof page , 'number' ) ;
assert . strictEqual ( typeof perPage , 'number' ) ;
assert . strictEqual ( typeof options , 'object' ) ;
const data = [ ] ;
let query = ` SELECT ${ TASKS _FIELDS } FROM tasks ` ;
if ( options . type ) {
query += ' WHERE TYPE=?' ;
data . push ( options . type ) ;
} else if ( options . prefix ) {
query += ' WHERE TYPE LIKE ' + mysql . escape ( options . prefix + '%' ) ;
}
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 ;
}
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' ) ;
2025-10-06 19:28:47 +02:00
const results = await list ( 1 , 1 , { type } ) ;
2021-07-12 23:35:30 -07:00
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' ) ;
2018-12-09 03:20:00 -08:00
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-10-08 11:06:24 +02:00
async function stopTask ( id ) {
assert . strictEqual ( typeof id , 'string' ) ;
if ( ! gTasks [ id ] ) throw new BoxError ( BoxError . BAD _STATE , 'task is not active' ) ;
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
}
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 ,
2025-07-17 09:53:29 +02:00
encoding : 'utf8' ,
2025-07-17 09:50:43 +02:00
abortSignal : ac . signal ,
2025-07-17 09:53:29 +02:00
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
2025-07-17 09:53:29 +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
2025-07-17 09:53:29 +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
2025-07-17 09:53:29 +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
2025-07-17 09:53:29 +02:00
delete gTasks [ id ] ;
2025-07-17 01:51:04 +02:00
2025-07-17 09:53:29 +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
2025-07-17 09:53:29 +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!' ) ;
2025-07-17 09:53:29 +02:00
// 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 ) ;
2025-10-08 11:15:32 +02:00
await safe ( setCompleted ( id , { error : taskError } ) , { debug } ) ;
2025-07-17 01:51:04 +02:00
2025-07-17 09:53:29 +02:00
throw taskError ;
2018-11-29 16:13:01 -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
2025-07-16 21:53:22 +02:00
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
}
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 } ) ;
2024-02-24 17:18:38 +01:00
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 ) {
2025-07-17 09:53:29 +02:00
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' ) ;
}