diff --git a/src/asynctask.js b/src/asynctask.js new file mode 100644 index 000000000..12239c421 --- /dev/null +++ b/src/asynctask.js @@ -0,0 +1,50 @@ +'use strict'; + +const debug = require('debug')('box:asynctask'), + EventEmitter = require('events'), + safe = require('safetydance'); + +// this runs in-process +class AsyncTask extends EventEmitter { + #name; + #abortController; + + constructor(name) { + super(); + this.#name = name; + this.#abortController = new AbortController(); + } + + async _run(/*signal*/) { + // subclasses implement this + } + + async start() { + debug(`start: ${this.#name} started`); + const [error] = await safe(this._run(this.#abortController.signal)); // background + debug(`start: ${this.#name} done`, error); + this.done(error); + } + + stop() { + debug(`stop: ${this.#name} stopped`); + this.#abortController.abort(); + } + + done(error) { + debug(`done: ${this.#name} finished`); + this.emit('done', { errorMessage: error?.message || '' }); + } + + emitProgress(percent, message) { + this.emit('data', 'progress', { percent, message }); + } + + emitData(obj) { + this.emit('data', 'data', obj); + } +} + +exports = module.exports = { + AsyncTask +}; diff --git a/src/paths.js b/src/paths.js index 074b14404..e0b85e343 100644 --- a/src/paths.js +++ b/src/paths.js @@ -44,7 +44,6 @@ exports = module.exports = { BACKUP_INFO_DIR: path.join(baseDir(), 'platformdata/backup'), UPDATE_DIR: path.join(baseDir(), 'platformdata/update'), BOX_UPDATE_FILE: path.join(baseDir(), 'platformdata/update/boxupdate.json'), - DISK_USAGE_CACHE_FILE: path.join(baseDir(), 'platformdata/diskusage/cache.json'), DISK_USAGE_EXCLUDE_FILE: path.join(baseDir(), 'platformdata/diskusage/exclude'), SNAPSHOT_INFO_FILE: path.join(baseDir(), 'platformdata/backup/snapshot-info.json'), DYNDNS_INFO_FILE: path.join(baseDir(), 'platformdata/dyndns-info.json'), diff --git a/src/routes/system.js b/src/routes/system.js index 61690012d..7af29f7f6 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -3,8 +3,6 @@ exports = module.exports = { reboot, getInfo, - getDiskUsage, - updateDiskUsage, getMemory, getLogs, getLogStream, @@ -12,6 +10,7 @@ exports = module.exports = { getMetricStream, getBlockDevices, getFilesystems, + getFilesystemUsage, getCpus, }; @@ -37,20 +36,6 @@ async function getInfo(req, res, next) { next(new HttpSuccess(200, { info })); } -async function getDiskUsage(req, res, next) { - const [error, result] = await safe(system.getDiskUsage()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(200, { usage: result })); -} - -async function updateDiskUsage(req, res, next) { - const [error, taskId] = await safe(system.startUpdateDiskUsage()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(201, { taskId })); -} - async function getMemory(req, res, next) { const [error, result] = await safe(system.getMemory()); if (error) return next(BoxError.toHttpError(error)); @@ -183,3 +168,34 @@ async function getFilesystems(req, res, next) { next(new HttpSuccess(200, { filesystems })); } + +async function getFilesystemUsage(req, res, next) { + if (typeof req.query.filesystem !== 'string') return next(new HttpError(400, 'getFilesystemUsage')); + if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); + + const [error, task] = await safe(system.getFilesystemUsage(req.query.filesystem)); + if (error) return next(BoxError.toHttpError(error)); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + 'Access-Control-Allow-Origin': '*' + }); + res.write('retry: 30000\n'); // client should .close() to prevent reconnect within 30s + res.on('close', () => task.stop()); + task.on('data', function (type, data) { + const obj = { type, ...data }; + const sse = `data: ${JSON.stringify(obj)}\n\n`; + res.write(sse); + }); + task.on('done', function (error) { + const obj = { type: 'done', ...error }; + const sse = `data: ${JSON.stringify(obj)}\n\n`; + res.write(sse); + res.end(); + }); + + task.start(); +} diff --git a/src/routes/test/system-test.js b/src/routes/test/system-test.js index f0f5393b8..c74bcfbed 100644 --- a/src/routes/test/system-test.js +++ b/src/routes/test/system-test.js @@ -12,7 +12,6 @@ const constants = require('../../constants.js'), http = require('http'), os = require('os'), paths = require('../../paths.js'), - safe = require('safetydance'), superagent = require('@cloudron/superagent'); describe('System', function () { @@ -130,8 +129,6 @@ describe('System', function () { describe('disk usage', function () { it('get succeeds with no cache', async function () { - safe.fs.unlinkSync(paths.DISK_USAGE_CACHE_FILE); - const response = await superagent.get(`${serverUrl}/api/v1/system/disk_usage`) .query({ access_token: owner.token }); diff --git a/src/server.js b/src/server.js index 5602b229d..7f1939e2f 100644 --- a/src/server.js +++ b/src/server.js @@ -119,10 +119,9 @@ async function initializeExpressSync() { router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot); router.get ('/api/v1/system/metrics', token, authorizeAdmin, routes.system.getMetrics); router.get ('/api/v1/system/metricstream', token, authorizeAdmin, routes.system.getMetricStream); - router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage); - router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage); router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices); router.get ('/api/v1/system/filesystems', token, authorizeAdmin, routes.system.getFilesystems); + router.post('/api/v1/system/filesystem_usage', token, authorizeAdmin, routes.system.getFilesystemUsage); router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs); router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream); // app operators require cpu and memory info for the Resources UI diff --git a/src/system.js b/src/system.js index 3ded5d228..580ee62bb 100644 --- a/src/system.js +++ b/src/system.js @@ -8,19 +8,18 @@ exports = module.exports = { getSwaps, checkDiskSpace, getMemory, - getDiskUsage, - updateDiskUsage, - startUpdateDiskUsage, getLogs, getBlockDevices, runSystemChecks, getProvider, getCpus, - getFilesystems + getFilesystems, + getFilesystemUsage }; const apps = require('./apps.js'), assert = require('assert'), + { AsyncTask } = require('./asynctask.js'), backups = require('./backups.js'), BoxError = require('./boxerror.js'), debug = require('debug')('box:system'), @@ -35,26 +34,27 @@ const apps = require('./apps.js'), paths = require('./paths.js'), safe = require('safetydance'), shell = require('./shell.js')('system'), - tasks = require('./tasks.js'), volumes = require('./volumes.js'); const DU_CMD = path.join(__dirname, 'scripts/du.sh'); const HDPARM_CMD = path.join(__dirname, 'scripts/hdparm.sh'); const REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'); -async function du(file) { +async function du(file, options) { assert.strictEqual(typeof file, 'string'); + assert.strictEqual(typeof options, 'object'); - const [error, stdoutResult] = await safe(shell.sudo([ DU_CMD, file ], { encoding: 'utf8' })); + const [error, stdoutResult] = await safe(shell.sudo([ DU_CMD, file ], { encoding: 'utf8', signal: options.signal })); if (error) throw new BoxError(BoxError.FS_ERROR, error); return parseInt(stdoutResult.trim(), 10); } -async function hdparm(file) { +async function hdparm(file, options) { assert.strictEqual(typeof file, 'string'); + assert.strictEqual(typeof options, 'object'); - const [error, stdoutResult] = await safe(shell.sudo([ HDPARM_CMD, file ], { encoding: 'utf8' })); + const [error, stdoutResult] = await safe(shell.sudo([ HDPARM_CMD, file ], { encoding: 'utf8', signal: options.signal })); if (error) throw new BoxError(BoxError.FS_ERROR, error); const lines = stdoutResult.split('\n'); @@ -218,62 +218,57 @@ async function getMemory() { }; } -async function getDiskUsage() { - const cache = safe.JSON.parse(safe.fs.readFileSync(paths.DISK_USAGE_CACHE_FILE, 'utf8')); - if (cache?.disks) { - cache.filesystems = cache.disks; // legacy cache file had "disks" - delete cache.disks; +class FilesystemUsageTask extends AsyncTask { + #filesystem; + + constructor(filesystem) { + super(`FileSystemUsageTask(${filesystem.filesystem})`); + this.#filesystem = filesystem; } - return cache; -} -async function updateDiskUsage(progressCallback) { - assert.strictEqual(typeof progressCallback, 'function'); + async _run(signal) { + const { filesystem, type, contents } = this.#filesystem; - const filesystems = await getFilesystems(); - const now = Date.now(); + let percent = 5; - let percent = 1; - - const dockerDf = await docker.df(); - const excludePaths = (safe.fs.readFileSync(paths.DISK_USAGE_EXCLUDE_FILE, 'utf8') || '').split('\n'); - - const fsCount = Object.keys(filesystems).length; - for (const fsPath in filesystems) { - const filesystem = filesystems[fsPath]; - - if (filesystem.type === 'ext4' || filesystem.type === 'xfs') { // hdparm only works with block devices - const [speedError, speed] = await safe(hdparm(fsPath)); - if (speedError) progressCallback({ message: `hdparm error: ${speedError.message}`}); - filesystem.speed = speedError ? -1 : speed; + if (type === 'ext4' || type === 'xfs') { // hdparm only works with block devices + this.emitProgress(percent, 'Calculating Disk Speed'); + const [speedError, speed] = await safe(hdparm(filesystem, { signal })); + if (speedError) debug(`hdparm error ${filesystem}: ${speedError.message}`); + this.emitData({ speed: speedError ? -1 : speed }); } else { - filesystem.speed = -1; + this.emitData({ speed: -1 }); } - percent += (100/fsCount); - progressCallback({ percent, message: `Checking contents of ${fsPath}`}); + const dockerDf = await docker.df(); - for (const content of filesystem.contents) { - progressCallback({ message: `Checking du of ${content.id} ${content.path}`}); + for (const content of contents) { + percent += (100/contents.length); + if (signal.aborted) return; + + this.emitProgress(percent,`Checking du of ${content.id} ${content.path}`); if (content.id === 'docker') { content.usage = dockerDf.LayersSize; } else if (content.id === 'docker-volumes') { content.usage = dockerDf.Volumes.map((v) => v.UsageData.Size).reduce((a,b) => a + b, 0); - } else if (excludePaths.includes(fsPath)) { - debug(`updateDiskUsage: skipping since path ${fsPath} is excluded`); - content.usage = 0; } else { - const [error, usage] = await safe(du(content.path)); - if (error) progressCallback({ message: `du error: ${error.message}`}); // can happen if app is installing etc + const [error, usage] = await safe(du(content.path, { signal })); + if (error) debug(`du error ${content.path}: ${error.message}`); // can happen if app is installing etc content.usage = usage || 0; } - progressCallback({ message: `du of ${JSON.stringify(content)}: ${content.usage}`}); + this.emitData({ content }); } } +} - if (!safe.fs.writeFileSync(paths.DISK_USAGE_CACHE_FILE, JSON.stringify({ ts: now, filesystems }, null, 4), 'utf8')) throw new BoxError(BoxError.FS_ERROR, `Could not write du cache file: ${safe.error.message}`); +async function getFilesystemUsage(fsPath) { + assert.strictEqual(typeof fsPath, 'string'); - return filesystems; + const filesystems = await getFilesystems(); + if (!(fsPath in filesystems)) throw new BoxError(BoxError.BAD_FIELD, 'No such filesystem'); + + const filesystem = filesystems[fsPath]; + return new FilesystemUsageTask(filesystem); } async function reboot() { @@ -303,12 +298,6 @@ async function getInfo() { }; } -async function startUpdateDiskUsage() { - const taskId = await tasks.add(tasks.TASK_UPDATE_DISK_USAGE, []); - safe(tasks.startTask(taskId, {}), { debug }); // background - return taskId; -} - async function getLogs(unit, options) { assert.strictEqual(typeof unit, 'string'); assert(options && typeof options === 'object'); diff --git a/src/tasks.js b/src/tasks.js index 01795e6ed..0b5a6e1f0 100644 --- a/src/tasks.js +++ b/src/tasks.js @@ -29,7 +29,6 @@ exports = module.exports = { TASK_SYNC_EXTERNAL_LDAP: 'syncExternalLdap', TASK_CHANGE_MAIL_LOCATION: 'changeMailLocation', TASK_SYNC_DNS_RECORDS: 'syncDnsRecords', - TASK_UPDATE_DISK_USAGE: 'updateDiskUsage', // error codes ESTOPPED: 'stopped', diff --git a/src/taskworker.js b/src/taskworker.js index b387522ae..f73ad5160 100755 --- a/src/taskworker.js +++ b/src/taskworker.js @@ -16,7 +16,6 @@ const apptask = require('./apptask.js'), net = require('net'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), - system = require('./system.js'), tasks = require('./tasks.js'), updater = require('./updater.js'); @@ -31,7 +30,6 @@ const TASKS = { // indexed by task type changeMailLocation: mailServer.changeLocation, syncDnsRecords: dns.syncDnsRecords, syncDyndns: dyndns.sync, - updateDiskUsage: system.updateDiskUsage, _identity: async (arg, progressCallback) => { progressCallback({ percent: 20 }); return arg; }, _error: async (arg, progressCallback) => { progressCallback({ percent: 20 }); throw new Error(`Failed for arg: ${arg}`); }, diff --git a/src/test/common.js b/src/test/common.js index 9253515e9..12cdf4202 100644 --- a/src/test/common.js +++ b/src/test/common.js @@ -14,7 +14,6 @@ const apps = require('../apps.js'), mailServer = require('../mailserver.js'), nock = require('nock'), path = require('path'), - paths = require('../paths.js'), settings = require('../settings.js'), tasks = require('../tasks.js'), timers = require('timers/promises'), @@ -229,7 +228,6 @@ async function domainSetup() { } async function setup() { - await fs.promises.rm(paths.DISK_USAGE_CACHE_FILE, { force: true }); await domainSetup(); const ownerId = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource); admin.id = ownerId; diff --git a/src/test/system-test.js b/src/test/system-test.js index 81edb1ca9..f9124ef0e 100644 --- a/src/test/system-test.js +++ b/src/test/system-test.js @@ -24,9 +24,9 @@ describe('System', function () { // does not work on archlinux 8! if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return; - const disks = await system.getFilesystems(); - expect(disks).to.be.ok(); - expect(Object.keys(disks).some(fs => disks[fs].mountpoint === '/')).to.be.ok(); + const filesystems = await system.getFilesystems(); + expect(filesystems).to.be.ok(); + expect(Object.keys(filesystems).some(fs => filesystems[fs].mountpoint === '/')).to.be.ok(); }); it('can get swaps', async function () { @@ -57,6 +57,16 @@ describe('System', function () { expect(cpus[0].model).to.be.a('string'); }); + it('can get ubuntu version', async function () { + const v = await system.getUbuntuVersion(); + expect(v).to.be.ok(); + }); + + it('can get kernel version', async function () { + const v = await system.getKernelVersion(); + expect(v).to.be.ok(); + }); + it('can get info', async function () { const info = await system.getInfo(); @@ -66,19 +76,20 @@ describe('System', function () { expect(info.rebootRequired).to.be.a('boolean'); }); - it('can get diskUsage', async function () { - const usage = await system.getDiskUsage(); - expect(usage).to.be(null); // nothing cached - }); + it('can get filesystemUsage', async function () { + const filesystems = await system.getFilesystems(); + const rootFs = Object.values(filesystems).find(v => v.mountpoint === '/'); + expect(rootFs.filesystem).to.be.ok(); - it('can updateDiskUsage', async function () { - const usage = await system.updateDiskUsage(function () {}); - const tmp = usage[Object.keys(usage)[0]]; + const usageTask = await system.getFilesystemUsage(rootFs.filesystem); - expect(tmp.size).to.be.greaterThan(0); - expect(tmp.used).to.be.greaterThan(0); - expect(tmp.available).to.be.greaterThan(0); - expect(tmp.capacity).to.be.greaterThan(0); - expect(tmp.speed).to.be.a('number'); + return new Promise((resolve, reject) => { + usageTask.on('data', (type, data) => console.log(type, data)); + usageTask.on('done', (error) => { + if (error.errorMessage) reject(new Error(error.errorMessage)); else resolve(); + }); + + usageTask.start(); + }); }); });