From 7244a506aebb20d89caf4a7bb726465ed21ce0dc Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Sat, 20 Apr 2019 20:16:00 +0200 Subject: [PATCH] WIP: Hardware query API --- .graphqlconfig | 3 + package.json | 27 +- src/errors.js | 27 ++ src/exec-binary.js | 375 ++++++++++++++++++++++++++++ src/graphql-test.js | 490 +++++++++++++++++++++++++++++++++++++ src/make-units.js | 68 +++++ src/map-value.js | 11 + src/match-or-error.js | 17 ++ src/parse/bytes/iec.js | 44 ++++ src/parse/mount-options.js | 469 +++++++++++++++++++++++++++++++++++ src/parse/octal-mode.js | 80 ++++++ src/schemas/main.gql | 329 +++++++++++++++++++++++++ src/test-wrapper.js | 47 ++++ src/units/bytes/iec.js | 13 + src/units/time.js | 13 + src/wrappers/findmnt.js | 80 ++++++ src/wrappers/lsblk.js | 51 ++++ src/wrappers/lvm.js | 239 ++++++++++++++++++ src/wrappers/smartctl.js | 158 ++++++++++++ 19 files changed, 2537 insertions(+), 4 deletions(-) create mode 100644 .graphqlconfig create mode 100644 src/errors.js create mode 100644 src/exec-binary.js create mode 100644 src/graphql-test.js create mode 100644 src/make-units.js create mode 100644 src/map-value.js create mode 100644 src/match-or-error.js create mode 100644 src/parse/bytes/iec.js create mode 100644 src/parse/mount-options.js create mode 100644 src/parse/octal-mode.js create mode 100644 src/schemas/main.gql create mode 100644 src/test-wrapper.js create mode 100644 src/units/bytes/iec.js create mode 100644 src/units/time.js create mode 100644 src/wrappers/findmnt.js create mode 100644 src/wrappers/lsblk.js create mode 100644 src/wrappers/lvm.js create mode 100644 src/wrappers/smartctl.js diff --git a/.graphqlconfig b/.graphqlconfig new file mode 100644 index 0000000..2d477ab --- /dev/null +++ b/.graphqlconfig @@ -0,0 +1,3 @@ +{ + "schemaPath": "src/schemas/**/*.gql" +} diff --git a/package.json b/package.json index c3adfb5..5dc2d88 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "description": "A VPS management panel", "main": "index.js", "scripts": { - "watch": "gulp watch", - "gulp": "gulp", - "knex": "knex" + "dev": "NODE_ENV=development nodemon --ext js,pug --ignore node_modules --ignore src/client bin/server.js" }, "repository": { "type": "git", @@ -17,37 +15,56 @@ "dependencies": { "@joepie91/gulp-partial-patch-livereload-logger": "^1.0.1", "JSONStream": "^1.1.4", + "array.prototype.flat": "^1.2.1", "assure-array": "^1.0.0", "bhttp": "^1.2.4", + "bignumber.js": "^8.1.1", "bluebird": "^3.4.6", "body-parser": "^1.15.2", + "capitalize": "^2.0.0", "checkit": "^0.7.0", "create-error": "^0.3.1", "create-event-emitter": "^1.0.0", + "dataloader": "^1.4.0", "debounce": "^1.0.0", + "debug": "^4.1.1", "default-value": "^1.0.0", "end-of-stream": "^1.1.0", + "execall": "^1.0.0", "express": "^4.14.0", "express-promise-router": "^1.1.0", "express-ws": "^3.0.0", "fs-extra": "^3.0.1", + "function-rate-limit": "^1.1.0", + "graphql": "^14.2.1", + "joi": "^14.3.0", "knex": "^0.13.0", + "map-obj": "^3.0.0", "pg": "^6.1.0", "pug": "^2.0.0-beta6", "rfr": "^1.2.3", "scrypt-for-humans": "^2.0.5", "split": "^1.0.0", + "sse-channel": "^3.1.1", "through2": "^2.0.1", "uuid": "^2.0.2" }, "devDependencies": { + "@babel/core": "^7.1.6", + "@babel/preset-env": "^7.1.6", + "@babel/preset-react": "^7.0.0", "@joepie91/gulp-preset-es2015": "^1.0.1", "@joepie91/gulp-preset-scss": "^1.0.1", "babel-core": "^6.14.0", "babel-loader": "^6.4.1", "babel-preset-es2015": "^6.14.0", "babel-preset-es2015-riot": "^1.1.0", + "babelify": "^10.0.0", + "browserify-hmr": "^0.3.7", + "budo": "^11.5.0", "chokidar": "^1.6.0", + "eslint": "^5.16.0", + "eslint-plugin-react": "^7.12.4", "gulp": "^3.9.1", "gulp-cached": "^1.1.0", "gulp-livereload": "^3.8.1", @@ -57,7 +74,9 @@ "jade": "^1.11.0", "json-loader": "^0.5.4", "listening": "^0.1.0", - "nodemon": "^1.10.2", + "nodemon": "^1.18.11", + "react": "^16.6.3", + "react-hot-loader": "^4.3.12", "riot": "^3.6.1", "riotjs-loader": "^4.0.0", "tiny-lr": "^0.2.1", diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..3cebc16 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,27 @@ +'use strict'; + +const errorChain = require("error-chain"); + +let HttpError = errorChain("HttpError", { + exposeToUser: true +}); + +module.exports = { + UnauthorizedError: errorChain("UnauthorizedError", { + statusCode: 401 + }, HttpError), + ForbiddenError: errorChain("ForbiddenError", { + statusCode: 403 + }, HttpError), + + UnexpectedOutput: errorChain("UnexpectedOutput"), + ExpectedOutputMissing: errorChain("ExpectedOutputMissing"), + NonZeroExitCode: errorChain("NonZeroExitCode"), + CommandExecutionFailed: errorChain("CommandExecutionFailed"), + InvalidPath: errorChain("InvalidPath"), + InvalidName: errorChain("InvalidName"), + PartitionExists: errorChain("PartitionExists"), + VolumeGroupExists: errorChain("VolumeGroupExists"), + InvalidVolumeGroup: errorChain("InvalidVolumeGroup"), + PhysicalVolumeInUse: errorChain("PhysicalVolumeInUse"), +}; diff --git a/src/exec-binary.js b/src/exec-binary.js new file mode 100644 index 0000000..e32102e --- /dev/null +++ b/src/exec-binary.js @@ -0,0 +1,375 @@ +"use strict"; + +require("array.prototype.flat").shim(); + +const Promise = require("bluebird"); +const util = require("util"); +const execFileAsync = util.promisify(require("child_process").execFile); +const execAll = require("execall"); +const debug = require("debug")("cvm:execBinary"); + +const errors = require("./errors"); + +let None = Symbol("None"); + +/* FIXME: How to handle partial result parsing when an error is encountered in the parsing code? */ +/* FIXME: "terminal" flag for individual matches in exec-binary */ +/* FIXME: Test that flag-dash prevention in arguments works */ + +function keyToFlagName(key) { + if (key.startsWith("!")) { + return key.slice(1); + } else if (key.length === 1) { + return `-${key}`; + } else { + return `--${key}`; + } +} + +function flagValueToArgs(key, value) { + if (value === true) { + return [key]; + } else if (Array.isArray(value)) { + return value.map((item) => { + return flagValueToArgs(key, item); + }).flat(); + } else { + return [key, value]; + } +} + +function flagsToArgs(flags) { + return Object.keys(flags).map((key) => { + let value = flags[key]; + let flagName = keyToFlagName(key); + + return flagValueToArgs(flagName, value); + }).flat(); +} + +function regexExpectationsForChannel(object, channel) { + return object._settings.expectations.filter((expectation) => { + return expectation.channel === channel && expectation.type === "regex"; + }); +} + +function executeExpectation(expectation, stdout, stderr) { + let output = (expectation.channel === "stdout") ? stdout : stderr; + + if (expectation.type === "regex") { + if (expectation.regex.test(output)) { + return executeRegexExpectation(expectation, output); + } else { + return None; + } + } else if (expectation.type === "json") { + let parsedOutput = JSON.parse(output); + + if (expectation.callback != null) { + return expectation.callback(parsedOutput); + } else { + return parsedOutput; + } + } else { + throw new Error(`Unexpected expectation type: ${expectation.type}`); + } +} + +function executeRegexExpectation(expectation, input) { + function processResult(fullMatch, groups) { + if (expectation.callback != null) { + return expectation.callback(groups, fullMatch, input); + } else { + return groups; + } + } + + if (expectation.matchAll) { + let matches = execAll(expectation.regex, input); + + if (matches.length > 0) { /* FILEBUG: File issue on execall repo to document the no-match output */ + let results = matches.map((match) => { + return processResult(match.match, match.sub); + }).filter((result) => { + return (result !== None); + }); + + if (results.length > 0) { + return results; + } else { + return None; + } + } else { + return None; + } + } else { + let match = expectation.regex.exec(input); + + if (match != null) { + return processResult(match[0], match.slice(1)); + } else { + return None; + } + } +} + +function verifyRegex(regex, {matchAll}) { + if (matchAll === true && !regex.flags.includes("g")) { + throw new Error("You enabled the 'matchAll' option, but the specified regular expression is not a global one; you probably forgot to specify the 'g' flag"); + } +} + +function validateArguments(args) { + if (args.some((arg) => arg == null)) { + throw new Error("One or more arguments were undefined or null; this is probably a mistake in how you're calling the command"); + } else if (args.some((arg) => arg[0] === "-")) { + throw new Error("For security reasons, command arguments cannot start with a dash; use the 'withFlags' method if you want to specify flags"); + } +} + +module.exports = function createBinaryInvocation(command, args = []) { + /* FIXME: The below disallows dashes in the args, but not in the command. Is that what we want? */ + validateArguments(args); + + return { + _settings: { + asRoot: false, + singleResult: false, + atLeastOneResult: false, + jsonStdout: false, + jsonStderr: false, + expectations: [], + flags: {}, + environment: {} + }, + _withSettings: function (newSettings) { + let newObject = Object.assign({}, this, { + _settings: Object.assign({}, this._settings, newSettings) + }); + + /* FIXME: Make this ignore json expectations */ + let hasStdoutExpectations = (regexExpectationsForChannel(newObject, "stdout").length > 0); + let hasStderrExpectations = (regexExpectationsForChannel(newObject, "stderr").length > 0); + + if (newObject._settings.jsonStdout && hasStdoutExpectations) { + throw new Error("The 'expectJsonStdout' and 'expectStdout' options cannot be combined"); + } else if (newObject._settings.jsonStderr && hasStderrExpectations) { + throw new Error("The 'expectJsonStderr' and 'expectStderr' options cannot be combined"); + } else { + return newObject; + } + }, + asRoot: function () { + return this._withSettings({ asRoot: true }); + }, + singleResult: function () { + return this._withSettings({ singleResult: true }); + }, + atLeastOneResult: function () { + return this._withSettings({ atLeastOneResult: true }); + }, + /* NOTE: Subsequent withFlags calls involving the same flag key will *override* the earlier value, not add to it! */ + withFlags: function (flags) { + if (flags != null) { + return this._withSettings({ + flags: Object.assign({}, this._settings.flags, flags) + }); + } else { + return this; + } + }, + withEnvironment: function (environment) { + if (environment != null) { + return this._withSettings({ + environment: Object.assign({}, this._settings.environment, environment) + }); + } else { + return this; + } + }, + withModifier: function (modifierFunction) { + if (modifierFunction != null) { + return modifierFunction(this); + } else { + return this; + } + }, + expectJsonStdout: function (callback) { + if (!this._settings.jsonStdout) { + return this._withSettings({ + jsonStdout: true, + expectations: this._settings.expectations.concat([{ + type: "json", + channel: "stdout", + key: "stdout", + callback: callback + }]) + }); + } + }, + expectJsonStderr: function (callback) { + if (!this._settings.jsonStderr) { + return this._withSettings({ + jsonStderr: true, + expectations: this._settings.expectations.concat([{ + type: "json", + channel: "stderr", + key: "stderr", + callback: callback + }]) + }); + } + }, + expectStdout: function (key, regex, {required, result, matchAll} = {}) { + verifyRegex(regex, {matchAll}); + + return this._withSettings({ + expectations: this._settings.expectations.concat([{ + type: "regex", + channel: "stdout", + required: (required === true), + key: key, + regex: regex, + callback: result, + matchAll: matchAll + }]) + }); + }, + expectStderr: function (key, regex, {required, result, matchAll} = {}) { + verifyRegex(regex, {matchAll}); + + return this._withSettings({ + expectations: this._settings.expectations.concat([{ + type: "regex", + channel: "stderr", + required: (required === true), + key: key, + regex: regex, + callback: result, + matchAll: matchAll + }]) + }); + }, + then: function () { + throw new Error("Attempted to use a command builder as a Promise; you probably forgot to call .execute"); + }, + execute: function () { + return Promise.try(() => { + let effectiveCommand = command; + let effectiveArgs = flagsToArgs(this._settings.flags).concat(args); + + if (this._settings.asRoot) { + effectiveCommand = "sudo"; + effectiveArgs = [command].concat(effectiveArgs); + } + + let effectiveCompleteCommand = [effectiveCommand].concat(effectiveArgs); + + return Promise.try(() => { + debug(`Running: ${effectiveCommand} ${effectiveArgs.map((arg) => `"${arg}"`).join(" ")}`); + + return execFileAsync(effectiveCommand, effectiveArgs, { + env: Object.assign({}, process.env, this._settings.environment) + }); + }).then(({stdout, stderr}) => { + return { stdout, stderr, exitCode: 0 }; + }).catch((error) => { + let {stdout, stderr} = error; + + let exitCode = (typeof error.code === "number") ? error.code : null; + + return { stdout, stderr, error, exitCode }; + }).then(({stdout, stderr, error, exitCode}) => { + let finalResult, resultFound; + + try { + if (this._settings.singleResult) { + let result = None; + let i = 0; + + while (result === None && i < this._settings.expectations.length) { + let expectation = this._settings.expectations[i]; + + result = executeExpectation(expectation, stdout, stderr); + + if (expectation.required === true && result === None) { + throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, { + exitCode: exitCode, + stdout: stdout, + stderr: stderr + }); + } + + i += 1; + } + + finalResult = result; + resultFound = (finalResult !== None); + } else { + let results = this._settings.expectations.map((expectation) => { + let result = executeExpectation(expectation, stdout, stderr); + + if (result === None) { + if (expectation.required === true) { + throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, { + exitCode: exitCode, + stdout: stdout, + stderr: stderr + }); + } else { + return result; + } + } else { + return { key: expectation.key, value: result }; + } + }).filter((result) => { + return (result !== None); + }); + + resultFound = (results.length > 0); + + finalResult = results.reduce((object, {key, value}) => { + return Object.assign(object, { + [key]: value + }); + }, {}); + } + } catch (processingError) { + throw errors.UnexpectedOutput.chain(processingError, "An error occurred while processing command output", { + command: effectiveCompleteCommand, + exitCode: exitCode, + stdout: stdout, + stderr: stderr + }); + } + + if (resultFound || this._settings.atLeastOneResult === false) { + if (error != null) { + throw new errors.NonZeroExitCode.chain(error, `Process '${command}' exited with code ${exitCode}`, { + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + result: finalResult + }); + } else { + return { + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + result: finalResult + }; + } + } else { + throw new errors.ExpectedOutputMissing("None of the expected outputs for the command were encountered, but at least one result is required", { + exitCode: exitCode, + stdout: stdout, + stderr: stderr + }); + } + }).catch(errors.CommandExecutionFailed.rethrowChained(`An error occurred while executing '${command}'`, { + command: effectiveCompleteCommand + })); + }); + } + }; +}; \ No newline at end of file diff --git a/src/graphql-test.js b/src/graphql-test.js new file mode 100644 index 0000000..8d8057e --- /dev/null +++ b/src/graphql-test.js @@ -0,0 +1,490 @@ +"use strict"; + +const Promise = require("bluebird"); +const graphql = require("graphql"); +const DataLoader = require("dataloader"); +const util = require("util"); +const fs = require("fs"); +const path = require("path"); +const chalk = require("chalk"); + +const matchOrError = require("./match-or-error"); +const lsblk = require("./wrappers/lsblk"); +const smartctl = require("./wrappers/smartctl"); +const lvm = require("./wrappers/lvm"); + +function gql(strings) { + return strings.join(""); +} + +function debugDisplay(results) { + if (results.errors != null && results.errors.length > 0) { + results.errors.forEach((graphqlError) => { + let errorHeader; + + if (graphqlError.path != null) { + errorHeader = `Error occurred for path: ${graphqlError.path.join(" -> ")}`; + } else if (graphqlError.locations != null && graphqlError.locations.length > 0) { + errorHeader = `Error occurred at line ${graphqlError.locations[0].line}, column ${graphqlError.locations[0].column}`; + } else { + errorHeader = "Error occurred in GraphQL"; + } + + console.log(chalk.bgBlue.white(errorHeader)); + + let error = graphqlError.originalError; + + if (error != null) { + if (error.showChain != null) { + console.log(error.showChain()); + } else { + console.log(error.stack); + } + } else { + console.log(graphqlError.stack); + } + + console.log("-----------------------------"); + }); + } + + console.log(util.inspect(results.data, {colors: true, depth: null})); +} + +/* FIXME: This seems to be added into a global registry somehow? How to specify this explicitly on a query without relying on globals? */ +new graphql.GraphQLScalarType({ + name: "ByteSize", + description: "A value that represents a value on a byte scale", + serialize: (value) => { + return JSON.stringify(value); + }, + parseValue: (value) => { + return JSON.parse(value); + }, + parseLiteral: (value) => { + return JSON.parse(value); + }, +}); + +new graphql.GraphQLScalarType({ + name: "TimeSize", + description: "A value that represents a value on a time scale", + serialize: (value) => { + return JSON.stringify(value); + }, + parseValue: (value) => { + return JSON.parse(value); + }, + parseLiteral: (value) => { + return JSON.parse(value); + }, +}); + +function withProperty(dataSource, id, property) { + return withData(dataSource, id, (value) => { + return value[property]; + }); +} + +function withData(dataSource, id, callback) { + return function (_, {data}) { + return Promise.try(() => { + if (data[dataSource] != null) { + return data[dataSource].load(id); + } else { + throw new Error(`Specified data source '${dataSource}' does not exist`); + } + }).then((value) => { + if (value != null) { + return callback(value); + } else { + throw new Error(`Got a null value from data source '${dataSource}' for ID '${id}'`); + } + }); + }; +} + +let All = Symbol("All"); + +function createLoaders() { + /* The below is to ensure that commands that produce a full list of all possible items, only ever get called and processed *once* per query, no matter what data is requested. */ + let lsblkPromise; + let smartctlPromise; + let lvmPhysicalVolumesPromise; + + return { + lsblk: new DataLoader((names) => { + return Promise.try(() => { + if (lsblkPromise == null) { + lsblkPromise = Promise.try(() => { + return lsblk(); + }).then((devices) => { + return { + tree: devices, + list: linearizeDevices(devices) + }; + }); + } + + return lsblkPromise; + }).then(({tree, list}) => { + return names.map((name) => { + if (name === All) { + return tree; + } else { + return list.find((device) => device.name === name); + } + }); + }); + }), + smartctlScan: new DataLoader((paths) => { + return Promise.try(() => { + if (smartctlPromise == null) { + smartctlPromise = smartctl.scan(); + } + + return smartctlPromise; + }).then((devices) => { + return paths.map((path) => { + if (path === All) { + return devices; + } else { + return devices.find((device) => device.path === path); + } + }); + }); + }), + smartctlInfo: new DataLoader((paths) => { + return Promise.map(paths, (path) => { + return smartctl.info({ devicePath: path }); + }); + }), + smartctlAttributes: new DataLoader((paths) => { + return Promise.map(paths, (path) => { + return smartctl.attributes({ devicePath: path }); + }); + }), + lvmPhysicalVolumes: new DataLoader((paths) => { + return Promise.try(() => { + if (lvmPhysicalVolumesPromise == null) { + lvmPhysicalVolumesPromise = lvm.getPhysicalVolumes(); + } + + return lvmPhysicalVolumesPromise; + }).then((volumes) => { + return paths.map((path) => { + if (path === All) { + return volumes; + } else { + return volumes.find((device) => device.path === path); + } + }); + }); + }), + }; +} + +let ID = Symbol("ID"); +let LocalProperties = Symbol("localProperties"); + +function createDataObject(mappings) { + let object = {}; + + if (mappings[LocalProperties] != null) { + Object.assign(object, mappings[LocalProperties]); + } + + for (let [dataSource, items] of Object.entries(mappings)) { + if (items[ID] != null) { + let id = items[ID]; + + for (let [property, source] of Object.entries(items)) { + if (typeof source === "string") { + object[property] = withProperty(dataSource, id, source); + } else if (typeof source === "function") { + object[property] = withData(dataSource, id, source); + } + } + } else { + throw new Error(`No object ID was provided for the '${dataSource}' data source`); + } + } + + return object; +} + +// ############################################### + +let schema = graphql.buildSchema(fs.readFileSync(path.resolve(__dirname, "./schemas/main.gql"), "utf8")); + +function createBlockDevice({ name, path }) { + if (name != null) { + path = `/dev/${name}`; + } else if (path != null) { + let match = matchOrError(/^\/dev\/(.+)$/, path); + name = match[0]; + } + + /* FIXME: parent */ + + return createDataObject({ + [LocalProperties]: { + path: path + }, + lsblk: { + [ID]: name, + name: "name", + size: "size", + mountpoint: "mountpoint", + deviceNumber: "deviceNumber", + removable: "removable", + readOnly: "readOnly", + children: (device) => { + return device.children.map((child) => { + return createBlockDevice({ name: child.name }); + }); + } + } + }); +} + +function createPhysicalVolume({ path }) { + return createDataObject({ + [LocalProperties]: { + path: path, + blockDevice: () => { + return createBlockDevice({ path: path }); + } + }, + lvmPhysicalVolumes: { + [ID]: path, + volumeGroup: (volume) => { + if (volume.volumeGroup != null) { + return createVolumeGroup({ name: volume.volumeGroup }); + } + }, + format: "format", + size: "totalSpace", + freeSpace: "freeSpace", + duplicate: "isDuplicate", + allocatable: "isAllocatable", + used: "isUsed", + exported: "isExported", + missing: "isMissing" + } + }); +} + +function createVolumeGroup({ name }) { + return createDataObject({ + [LocalProperties]: { + name: name + } + }); +} + +function createDrive({ path }) { + return createDataObject({ + [LocalProperties]: { + path: path, + blockDevice: () => { + return createBlockDevice({ path: path }); + }, + /* FIXME: allBlockDevices, for representing every single block device that's hosted on this physical drive, linearly. Need to figure out how that works with representation of mdraid arrays, LVM volumes, etc. */ + }, + smartctlScan: { + [ID]: path, + interface: "interface" + }, + smartctlInfo: { + [ID]: path, + model: "model", + modelFamily: "modelFamily", + smartAvailable: "smartAvailable", + smartEnabled: "smartEnabled", + serialNumber: "serialNumber", + wwn: "wwn", + firmwareVersion: "firmwareVersion", + size: "size", + rpm: "rpm", + logicalSectorSize: (device) => device.sectorSizes.logical, + physicalSectorSize: (device) => device.sectorSizes.physical, + formFactor: "formFactor", + ataVersion: "ataVersion", + sataVersion: "sataVersion" + } + }); +} + +function linearizeDevices(devices) { + let linearizedDevices = []; + + function add(list) { + for (let device of list) { + linearizedDevices.push(device); + + if (device.children != null) { + add(device.children); + } + } + } + + add(devices); + + return linearizedDevices; +} + +let root = { + hardware: { + drives: function ({ paths }, { data }) { + return Promise.try(() => { + if (paths != null) { + return data.smartctlScan.loadMany(paths); + } else { + return data.smartctlScan.load(All); + } + }).then((devices) => { + return devices.map((device) => { + return createDrive({ path: device.path }); + }); + }); + } + }, + resources: { + blockDevices: function ({ names }, { data }) { + return Promise.try(() => { + if (names != null) { + return data.lsblk.loadMany(names); + } else { + return data.lsblk.load(All); + } + }).then((devices) => { + return devices.map((device) => { + return createBlockDevice({ name: device.name }); + }); + }); + }, + lvm: { + physicalVolumes: function ({ paths }, { data }) { + return Promise.try(() => { + if (paths != null) { + return data.lvmPhysicalVolumes.loadMany(paths); + } else { + return data.lvmPhysicalVolumes.load(All); + } + }).then((volumes) => { + return volumes.map((volume) => { + return createPhysicalVolume({ path: volume.path }); + }); + }); + } + } + } +}; + +function makeQuery(query, args) { + return graphql.graphql(schema, query, root, { + data: createLoaders() + }, args); +} + +// FIXME: If we intend to target macOS, a lot of whitespace-based output splitting won't work: https://www.mail-archive.com/austin-group-l@opengroup.org/msg01678.html + +// findmnt --json -o +SIZE,AVAIL +// -> map back to mountPoint stuff? +// blkid +// to discover the filesystem that a given path exists on: stat -c %m +// partx +// (rest of util-linux) +// memory usage: /proc/meminfo + +return Promise.try(() => { + let query = gql` + # query SomeDrives($drivePaths: [String]) { + query SomeDrives { + # hardware { + # drives(paths: $drivePaths) { + # path + # interface + + # model + # modelFamily + # smartAvailable + # smartEnabled + # serialNumber + # wwn + # firmwareVersion + # size + # rpm + # logicalSectorSize + # physicalSectorSize + # formFactor + # ataVersion + # sataVersion + + # blockDevice { + # removable + + # children { + # name + # mountpoint + # size + # } + # } + # } + # } + + resources { + # blockDevices { + # name + # mountpoint + # size + # deviceNumber + # removable + # readOnly + # parent { name } + + # children { + # name + # mountpoint + # size + # deviceNumber + # removable + # readOnly + # parent { name } + # } + # } + + lvm { + physicalVolumes { + path + + blockDevice { + name + deviceNumber + } + + volumeGroup { + name + } + + format + size + freeSpace + duplicate + allocatable + used + exported + missing + } + } + } + } + `; + + return makeQuery(query, { + // drivePaths: ["/dev/sda", "/dev/sdb"] + }); +}).then((results) => { + debugDisplay(results); +}); \ No newline at end of file diff --git a/src/make-units.js b/src/make-units.js new file mode 100644 index 0000000..57b5e2d --- /dev/null +++ b/src/make-units.js @@ -0,0 +1,68 @@ +"use strict"; + +/* TODO: +toDisplay +conversion between unit scales (eg. IEC -> metric bytes) +*/ + +const util = require("util"); +const chalk = require("chalk"); + +function capitalize(string) { + return string[0].toUpperCase() + string.slice(1); +} + +module.exports = function makeUnits(unitSpecs) { + let resultObject = {}; + + unitSpecs.forEach((spec, i) => { + let proto = { + [util.inspect.custom]: function (_depth, options) { + let inspectString = ` ${this.amount} ${this.unit}`; + + if (options.colors === true) { + return chalk.cyan(inspectString); + } else { + return inspectString; + } + }, + toString: function () { + /* TODO: Make this auto-convert */ + return `${this.amount} ${this.unit}`; + }, + } + + unitSpecs.forEach((otherSpec, otherI) => { + let factor = 1; + + if (otherI < i) { + /* Convert downwards, to smaller units (== larger numbers) */ + unitSpecs.slice(otherI, i).reverse().forEach((specStep) => { + factor = factor * specStep.toNext; + }); + } else if (otherI > i) { + /* Convert upwards, to larger units (== smaller numbers) */ + unitSpecs.slice(i, otherI).forEach((specStep) => { + factor = factor / specStep.toNext; + }); + } + + proto[`to${capitalize(otherSpec.unit)}`] = function () { + return resultObject[otherSpec.unit](this.amount * factor); + }; + }); + + resultObject[spec.unit] = function createUnit(value) { + if (typeof value !== "number") { + throw new Error("Value must be numeric"); + } else { + return Object.assign(Object.create(proto), { + unit: spec.unit, + amount: value + }); + } + } + }); + + return resultObject; +}; \ No newline at end of file diff --git a/src/map-value.js b/src/map-value.js new file mode 100644 index 0000000..52ce627 --- /dev/null +++ b/src/map-value.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = function mapValue(value, mapping) { + if (value == null) { + return value; + } else if (mapping[value] != null) { + return mapping[value]; + } else { + throw new Error(`Unrecognized value: ${value}`); + } +}; \ No newline at end of file diff --git a/src/match-or-error.js b/src/match-or-error.js new file mode 100644 index 0000000..dca8410 --- /dev/null +++ b/src/match-or-error.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = function matchOrError(regex, string) { + if (regex == null) { + throw new Error("No regular expression was provided"); + } else if (string == null) { + throw new Error("No string to match on was provided"); + } else { + let match = regex.exec(string); + + if (match == null) { + throw new Error(`Regular expression ${regex.toString()} failed to match on string: ${string}`); + } else { + return match.slice(1); + } + } +}; \ No newline at end of file diff --git a/src/parse/bytes/iec.js b/src/parse/bytes/iec.js new file mode 100644 index 0000000..ec374e4 --- /dev/null +++ b/src/parse/bytes/iec.js @@ -0,0 +1,44 @@ +"use strict"; + +const {B, KiB, MiB, GiB, TiB, PiB, EiB} = require("../../units/bytes/iec"); + +let unitMap = { + b: B, + k: KiB, + m: MiB, + g: GiB, + t: TiB, + p: PiB, + e: EiB +}; + +function mapUnit(unitString) { + if (unitString == null) { + return B; + } else { + let normalizedUnitString = unitString.toLowerCase(); + + if (unitMap[normalizedUnitString] != null) { + return unitMap[normalizedUnitString]; + } else { + throw new Error(`Unknown unit: ${unit}`); + } + } +} + +module.exports = function parseIECBytes(sizeString) { + if (sizeString == null) { + return sizeString; + } else { + let match = /^([0-9]+(?:\.[0-9]+)?)([bkmgtpe])?$/i.exec(sizeString.trim()); + + if (match == null) { + throw new Error(`Could not parse size string: ${sizeString}`); + } + + let [_, number, unit] = match; + let unitCreator = mapUnit(unit); + + return unitCreator(parseFloat(number)); + } +}; \ No newline at end of file diff --git a/src/parse/mount-options.js b/src/parse/mount-options.js new file mode 100644 index 0000000..6e1c7b4 --- /dev/null +++ b/src/parse/mount-options.js @@ -0,0 +1,469 @@ +"use strict"; + +const mapObj = require("map-obj"); + +const {B, KiB} = require("../units/bytes/iec"); +const {minutes, seconds, microseconds} = require("../units/time"); +const mapValue = require("../map-value"); +const parseOctalMode = require("./octal-mode"); +const parseIECBytes = require("./bytes/iec"); +const matchOrError = require("../match-or-error"); + +let Value = (value) => value; +let NumericValue = (value) => parseInt(value); +let ByteValue = (value) => B(parseInt(value)); +let Include = Symbol("Include"); +let All = Symbol("All"); + +function MappedValue(mapping) { + return (value) => mapValue(value, mapping); +} + +let mountOptionMap = { + // https://www.systutorials.com/docs/linux/man/8-mount/ + /* TODO: UDF / iso9660? */ + /* TODO: sshfs, fuseiso, and other FUSE-y things? */ + [All]: { + async: { asynchronous: true }, + sync: { asynchronous: false }, + atime: { updateAccessTime: true }, + noatime: { updateAccessTime: false }, + auto: { automaticallyMountable: true }, + noauto: { automaticallyMountable: false }, + dev: { allowDeviceNodes: true }, + nodev: { allowDeviceNodes: false }, + diratime: { updateDirectoryAccessTime: true }, + nodiratime: { updateDirectoryAccessTime: false }, + dirsync: { asynchronousDirectoryUpdates: false }, + exec: { allowExecution: true }, + noexec: { allowExecution: false }, + iversion: { incrementIVersion: true }, + noiversion: { incrementIVersion: false }, + mand: { allowMandatoryLocks: true }, + nomand: { allowMandatoryLocks: true }, + _netdev: { requiresNetworkAccess: true }, + nofail: { reportMountingErrors: false }, + relatime: { updateAccessTimeRelatively: true }, + norelatime: { updateAccessTimeRelatively: false }, + strictatime: { updateAccessTimeStrictly: true }, + nostrictatime: { updateAccessTimeStrictly: false }, + suid: { allowSetUIDBits: true }, + nosuid: { allowSetUIDBits: false }, + rw: { writable: true }, + ro: { writable: false }, + user: { + userMountable: true, + allowExecution: false, + allowSetUIDBits: false, + allowDeviceNodes: false + }, + nouser: { userMountable: false }, + users: { + freelyMountable: true, + allowExecution: false, + allowSetUIDBits: false, + allowDeviceNodes: false + }, + _rnetdev: { + requiresNetworkAccess: true, + allowOfflineChecks: true + }, + owner: { + owner: Value, + allowSetUIDBits: false, + allowDeviceNodes: false + }, + group: { + group: Value, + allowSetUIDBits: false, + allowDeviceNodes: false + }, + defaults: { + writable: true, + allowSetUIDBits: true, + allowDeviceNodes: true, + allowExecution: true, + automaticallyMountable: true, + userMountable: false, + asynchronous: true, + updateAccessTimeRelatively: true + }, + /* Various specific filesystems support the below options */ + errors: { onError: Value }, + }, + _filesystemModes: { + uid: { filesystemOwnerId: Value }, + gid: { filesystemGroupId: Value }, + mode: { filesystemPermissions: (value) => parseOctalMode(value) }, + }, + tmpfs: { + uid: { rootOwnerId: Value }, + gid: { rootGroupId: Value }, + mode: { rootPermissions: (value) => parseOctalMode(value) }, + size: { size: ByteValue }, + nr_blocks: { blockCount: NumericValue }, + nr_inodes: { maximumInodes: NumericValue }, + mpol: { numaPolicy: Value }, + }, + devtmpfs: { + [Include]: ["tmpfs"] + }, + // https://www.systutorials.com/docs/linux/man/5-ext4/ + ext2: { + acl: { supportACL: true }, + noacl: { supportACL: false }, + bsddf: { showOnlyUsableSpace: true }, + minixdf: { showOnlyUsableSpace: false }, + check: (value) => { + if (value === "none") { + return { checkFilesystemOnMount: false }; + } + }, + nocheck: { checkFilesystemOnMount: false }, + debug: { printDebugInformation: true }, + grpid: { useGroupIDFromDirectory: true }, + bsdgroups: { useGroupIDFromDirectory: true }, + nogrpid: { useGroupIDFromDirectory: false }, + sysvgroups: { useGroupIDFromDirectory: false }, + grpquota: { enableGroupQuota: true }, + usrquota: { enableUserQuota: true }, + quota: { enableUserQuota: true }, + noquota: { + enableUserQuota: false, + enableGroupQuota: false + }, + bh: { attachBufferHeads: true }, + nobh: { attachBufferHeads: false }, + nouid32: { allow32bitIdentifiers: false }, + oldalloc: { allocator: "old" }, + orlov: { allocator: "orlov" }, + resgid: { reservedSpaceForGroupID: NumericValue }, + resuid: { reservedSpaceForUserID: NumericValue }, + sb: { superblockIndex: NumericValue }, + user_xattr: { allowExtendedAttributes: true }, + nouser_xattr: { allowExtendedAttributes: false }, + }, + ext3: { + [Include]: ["ext2"], + journal: (value) => { + if (value === "update") { + return { updateJournalFormat: true }; + } else { + return { journalInode: parseInt(value) }; + } + }, + journal_dev: { journalDeviceNumber: Value }, + journal_path: { journalPath: Value }, + norecovery: { processJournal: false }, + noload: { processJournal: false }, + data: { journalingMode: Value }, + data_err: { + onBufferError: MappedValue({ + ignore: "ignoreError", + abort: "abortJournal" + }) + }, + barrier: (value) => { + if (value === "0") { + return { enableWriteBarriers: false }; + } else if (value === "1") { + return { enableWriteBarriers: true }; + } else { + throw new Error(`Invalid value for 'barrier': ${value}`); + } + }, + commit: (value) => { + if (value === "0") { + return { commitInterval: null }; + } else { + return { commitInterval: seconds(parseInt(value)) }; + } + }, + jqfmt: { quotaSystem: Value }, + usrjquota: { userQuotaFile: Value }, + grpjquota: { groupQuotaFile: Value } + }, + ext4: { + [Include]: ["ext3"], + journal_checksum: { enableJournalChecksumming: true }, + nojournal_checksum: { enableJournalChecksumming: false }, + journal_async_commit: { + asynchronousJournalCommits: true, + enableJournalChecksumming: true + }, + barrier: (value) => { + if (value === "1" || value === true) { + return { enableWriteBarriers: true }; + } else if (value === "0") { + return { enableWriteBarriers: false }; + } else { + throw new Error(`Invalid value for 'barrier': ${value}`); + } + }, + nobarrier: { enableWriteBarriers: false }, + inode_readahead_blks: { inodeBlockReadAheadLimit: NumericValue }, + stripe: { stripeBlocks: NumericValue }, + delalloc: { allowDeferredAllocations: true }, + nodelalloc: { allowDeferredAllocations: false }, + max_batch_time: { batchingTimeLimit: (value) => microseconds(parseInt(value)) }, + min_batch_time: { batchingTimeMinimum: (value) => microseconds(parseInt(value)) }, + journal_ioprio: { journalIOPriority: NumericValue }, + abort: { simulateAbort: true }, + auto_da_alloc: { automaticallySynchronizeBeforeRename: true }, + noauto_da_alloc: { automaticallySynchronizeBeforeRename: false }, + noinit_itable: { enableInodeTableBlockInitialization: false }, + init_itable: { + enableInodeTableBlockInitialization: true, + inodeTableBlockInitializationDelay: NumericValue + }, + discard: { enableHardwareDeleteCalls: true }, + nodiscard: { enableHardwareDeleteCalls: false }, + resize: { allowAutomaticFilesystemResizing: true }, + block_validity: { enableMetadataBlockTracking: true }, + noblock_validity: { enableMetadataBlockTracking: false }, + dioread_lock: { enableDirectIOReadLocking: true }, + nodioread_lock: { enableDirectIOReadLocking: false }, + max_dir_size_kb: { directorySizeLimit: (value) => KiB(parseInt(value)) }, + i_version: { allow64bitInodeVersions: true }, + nombcache: { enableMetadataBlockCache: false }, + prjquota: { enableProjectQuota: true } + }, + fat: { + [Include]: ["_filesystemModes"], + blocksize: { blockSize: ByteValue }, + umask: { + defaultFileMode: (value) => parseOctalMode("666", { mask: value }), + defaultFolderMode: (value) => parseOctalMode("777", { mask: value }), + }, + dmask: { defaultFolderMode: (value) => parseOctalMode("777", { mask: value }) }, + fmask: { defaultFileMode: (value) => parseOctalMode("666", { mask: value }) }, + /* TODO: Figure out a way to make the below nicer, interacting with dmask etc. */ + allow_utime: (value) => { + if (value === "2") { + return { timestampChangesAllowedFrom: "everybody" }; + } else if (value === "20") { + return { timestampChangesAllowedFrom: "group" }; + } + }, + check: { + nameStrictness: MappedValue({ + r: "relaxed", + relaxed: "relaxed", + n: "normal", + normal: "normal", + s: "strict", + strict: "strict" + }) + }, + codepage: { codepage: NumericValue }, + conv: { + lineEndingConversionPolicy: MappedValue({ + b: "binary", + binary: "binary", + t: "text", + text: "text", + a: "auto", + auto: "auto" + }) + }, + cvf_format: { compressedVolumeFileModule: Value }, + cvf_option: { compressedVolumeFileOption: Value }, + debug: { printDebugInformation: true }, + dos1xfloppy: { enableDOS1xFallbackConfiguration: true }, + fat: { allocationTableBitness: NumericValue }, + iocharset: { conversionCharacterSet: Value }, + nfs: (value) => { + if (value === true || value === "stale_rw") { + return { enableNFSInodeCache: true }; + } else if (value === "nostale_ro") { + return { + enableNFSInodeCache: false, + writable: false + }; + } else { + throw new Error(`Unrecognized value for 'nfs' option: ${value}`); + } + }, + tz: (value) => { + if (value === "UTC") { + return { enableTimezoneConversion: false }; + } else { + throw new Error(`Unrecognized value for 'tz' option: ${value}`); + } + }, + time_offset: { timestampOffset: (value) => minutes(parseInt(value)) }, + quiet: { enableQuietModeFailures: true }, + rodir: { enableReadOnlyFlagSupport: true }, + showexec: { restrictExecutableModeToWindowsBinaries: true }, + sys_immutable: { treatAttrSysFlagAsImmutable: true }, + flush: { enableEagerFlushing: true }, + usefree: { enableFreeClusterCache: true }, + // omitted: dots, nodots, dotsOK=[yes|no] + }, + vfat: { + [Include]: ["fat"], + uni_xlate: { enableUnicodeCharacterEscaping: true }, + posix: { allowDifferentlyCasedNameConflicts: true }, + nonumtail: { preferShortNamesWithoutSequenceNumber: true }, + utf8: (value) => { + if (value === true) { + return { supportUtf8: true }; + } else if (value === "false" || value === "0" || value === "no") { + return { supportUtf8: false }; + } else { + throw new Error(`Unrecognized value for 'utf8' option: ${value}`); + } + }, + shortname: { shortNameMode: Value } + }, + msdos: { + [Include]: ["fat"] + /* FIXME */ + }, + umsdos: { + [Include]: ["fat"] + /* FIXME */ + }, + devpts: { + uid: { newPTYOwnerId: Value }, + gid: { newPTYGroupId: Value }, + mode: { newPTYPermissions: (value) => parseOctalMode(value) }, + newinstance: { isolatePTYs: true }, + ptmxmode: { ptmxPermissions: (value) => parseOctalMode(value) } + }, + mqueue: { + /* This pseudo-filesystem does not appear to have any specific mount options. */ + }, + proc: { + /* This pseudo-filesystem does not appear to have any specific mount options. */ + }, + sysfs: { + // https://www.kernel.org/doc/Documentation/filesystems/sysfs.txt + /* This pseudo-filesystem does not appear to have any specific mount options. */ + }, + securityfs: { + // https://lwn.net/Articles/153366/ + /* This pseudo-filesystem does not appear to have any specific mount options. */ + }, + efivarfs: { + // https://www.kernel.org/doc/Documentation/filesystems/efivarfs.txt + /* This pseudo-filesystem does not appear to have any specific mount options. */ + }, + ramfs: { + /* FILEBUG: manpage for `ramfs` incorrectly claims that it has no mount options; it has `mode` (https://github.com/torvalds/linux/blob/master/fs/ramfs/inode.c#L41) */ + mode: { rootPermissions: (value) => parseOctalMode(value) }, + }, + debugfs: { + // https://www.kernel.org/doc/Documentation/filesystems/debugfs.txt + uid: { rootOwnerId: Value }, + gid: { rootGroupId: Value }, + mode: { rootPermissions: (value) => parseOctalMode(value) }, + }, + hugetlbfs: { + // https://www.kernel.org/doc/Documentation/vm/hugetlbpage.txt + uid: { rootOwnerId: Value }, + gid: { rootGroupId: Value }, + mode: { rootPermissions: (value) => parseOctalMode(value, { mask: "6000" }) }, + pagesize: { pageSize: (value) => parseIECBytes(value) }, + size: (value) => { + if (value.includes("%")) { + let [percentage] = matchOrError(/^([0-9]+(?:\.[0-9]+))%$/, value); + return { sizeAsPoolPercentage: parseFloat(percentage) }; + } else { + return { size: parseIECBytes(value) }; + } + }, + min_size: (value) => { + if (value.includes("%")) { + let [percentage] = matchOrError(/^([0-9]+(?:\.[0-9]+))%$/, value); + return { minimumSizeAsPoolPercentage: parseFloat(percentage) }; + } else { + return { minimumSize: parseIECBytes(value) }; + } + }, + nr_inodes: { maximumInodes: (value) => parseIECBytes(value) }, + }, + cgroup: { + /* TODO */ + }, + cgroup2: { + /* TODO */ + }, + bpf: { + /* TODO */ + }, + pstore: { + /* TODO */ + }, +}; + +function optionsForFilesystem(filesystem) { + let ownOptions = mountOptionMap[filesystem]; + + if (ownOptions == null) { + throw new Error(`No options found for filesystem '${filesystem}'`); + } + + if (ownOptions[Include] != null) { + return ownOptions[Include] + .map((target) => { + return optionsForFilesystem(target); + }) + .concat([ownOptions]) + .reduce((combined, targetOptions) => { + return Object.assign(combined, targetOptions); + }, {}); + } else { + return ownOptions; + } +} + +function applyMapping(sourceValue, mapping) { + if (typeof mapping === "function") { + return mapping(sourceValue); + } else { + return mapObj(mapping, (key, value) => { + let mappedValue; + + if (value === Value) { + mappedValue = sourceValue; + } else if (typeof value === "function") { + mappedValue = value(sourceValue); + } else { + mappedValue = value; + } + + return [key, mappedValue]; + }); + } +} + +module.exports = function parseOptions(filesystem, optionString) { + let optionMap = Object.assign({}, mountOptionMap[All], optionsForFilesystem(filesystem)); + + return optionString + .split(",") + .map((item) => { + if (item.includes("=")) { + let [key, value] = item.split("="); + return [ key, value ]; + } else { + return [ item, true ]; + } + }) + .reduce(({ parsed, missing }, [key, value]) => { + let mapping = optionMap[key]; + + if (mapping != null) { + return { + parsed: Object.assign(parsed, applyMapping(value, mapping)), + missing: missing + }; + } else { + return { + parsed: parsed, + missing: missing.concat(key) + }; + } + }, { parsed: {}, missing: [] }); +}; \ No newline at end of file diff --git a/src/parse/octal-mode.js b/src/parse/octal-mode.js new file mode 100644 index 0000000..f875c8e --- /dev/null +++ b/src/parse/octal-mode.js @@ -0,0 +1,80 @@ +"use strict"; + +function parseModeDigit(modeDigit) { + let integer = parseInt(modeDigit); + + return { + read: Boolean(integer & 4), + write: Boolean(integer & 2), + execute: Boolean(integer & 1) + }; +} + +function parseSpecialDigit(modeDigit) { + let integer = parseInt(modeDigit); + + return { + setUID: Boolean(integer & 4), + setGID: Boolean(integer & 2), + sticky: Boolean(integer & 1) + }; +} + +function mapModeDigits(digits, hasSpecialBits) { + /* NOTE: The hasSpecialBits setting indicates whether the zeroeth digit was user-supplied (as opposed to being a default 0). This ensures that the index of the other bits is always stable, but we still don't return any special-bit information if the user didn't ask for it. This is important because the behaviour of an omitted special-bits digit may differ from environment to environment, so it should be left up to the calling code to deal with how to interpret that, and we cannot assume here that it's correct to interpret it as "none of the special bits are set". */ + let normalModes = { + owner: parseModeDigit(digits[1]), + group: parseModeDigit(digits[2]), + everybody: parseModeDigit(digits[3]), + }; + + if (!hasSpecialBits) { + return normalModes; + } else { + return Object.assign(normalModes, parseSpecialDigit(digits[0])); + } +} + +function applyMask(target, mask) { + return (target & (~mask)); +} + +module.exports = function parseModeString(modeString, { mask } = {}) { + let hasSpecialBits = (modeString.length === 4); + let modeDigits = intoDigits(modeString); + let maskDigits; + + if (mask != null) { + maskDigits = intoDigits(mask); + } else { + maskDigits = [0, 0, 0, 0]; + } + + let maskedModeDigits = modeDigits.map((digit, i) => { + return applyMask(digit, maskDigits[i]) + }); + + return mapModeDigits(maskedModeDigits, hasSpecialBits); +}; + +function intoDigits(modeString) { + let parsedDigits = modeString + .split("") + .map((digit) => { + let parsedDigit = parseInt(digit); + + if (parsedDigit < 8) { + return parsedDigit; + } else { + throw new Error(`Mode string digit can only be 0-7, but encountered: ${digit}`); + } + }); + + if (parsedDigits.length === 3) { + return [0].concat(parsedDigits); + } else if (parsedDigits.length === 4) { + return parsedDigits; + } else { + throw new Error(`Unrecognized mode string length: ${modeString}`); + } +} \ No newline at end of file diff --git a/src/schemas/main.gql b/src/schemas/main.gql new file mode 100644 index 0000000..68e746e --- /dev/null +++ b/src/schemas/main.gql @@ -0,0 +1,329 @@ +scalar ByteSize +scalar TimeSize + +type AccessPermissions { + read: Boolean + write: Boolean + execute: Boolean +} + +type Permissions { + owner: AccessPermissions + group: AccessPermissions + everybody: AccessPermissions + setUID: Boolean + setGID: Boolean + sticky: Boolean +} + +type User { + name: String + id: Int + # FIXME +} + +type Group { + name: String + id: Int + # FIXME +} + +type RawMountValueOption { + key: String! + value: String +} + +type RawMountFlagOption { + key: String! +} + +union RawMountOption = RawMountFlagOption | RawMountValueOption + +enum MountErrorHandlingMode { + PANIC + CONTINUE + REMOUNT_READ_ONLY +} + +enum ExtAllocator { + OLD + ORLOV +} + +enum ExtJournalingMode { + JOURNAL + ORDERED + WRITEBACK +} + +enum ExtBufferErrorHandlingMode { + IGNORE_ERROR + ABORT_JOURNAL +} + +enum ExtQuotaSystem { + OLD + V0 + V1 +} + +enum FatTimestampsAllowedFrom { + EVERYBODY + GROUP + OWNER +} + +enum FatNameStrictness { + RELAXED + NORMAL + STRICT +} + +enum FatShortNameMode { + LOWER + WINDOWS_95 + WINDOWS_NT + MIXED +} + +enum FatLineEndingConversionPolicy { + BINARY + TEXT + AUTO +} + +type MountOptions { + writable: Boolean + userMountable: Boolean + freelyMountable: Boolean + + asynchronous: Boolean + asynchronousDirectoryUpdates: Boolean + + allowDeviceNodes: Boolean + allowExecution: Boolean + allowMandatoryLocks: Boolean + allowSetUIDBits: Boolean + + requiresNetworkAccess: Boolean + allowOfflineChecks: Boolean + + updateAccessTime: Boolean + updateDirectoryAccessTime: Boolean + updateAccessTimeRelatively: Boolean + updateAccessTimeStrictly: Boolean + + automaticallyMountable: Boolean + incrementIVersion: Boolean + printDebugInformation: Boolean + reportMountingErrors: Boolean + onError: MountErrorHandlingMode + + owner: User + group: Group + + filesystemOwner: User + filesystemGroup: Group + filesystemPermissions: Permissions + + # devpts + newPTYOwner: User + newPTYGroup: Group + newPTYPermissions: Permissions + + isolatePTYs: Boolean + ptmxPermissions: Permissions + + # tmpfs, hugetlbfs + rootOwner: User + rootGroup: Group + rootPermissions: Permissions + maximumInodes: Int + size: ByteSize + + # hugetlbfs + pageSize: ByteSize + sizeAsPoolPercentage: Float + minimumSize: ByteSize + minimumSizeAsPoolPercentage: Float + + # tmpfs + blockCount: Int + numaPolicy: String + + # ext2, ext3, ext4 + allow32bitIdentifiers: Boolean + allow64bitInodeVersions: Boolean + allowExtendedAttributes: Boolean + + supportACL: Boolean + showOnlyUsableSpace: Boolean + useGroupIDFromDirectory: Boolean + + allowAutomaticFilesystemResizing: Boolean + enableMetadataBlockTracking: Boolean + simulateAbort: Boolean + attachBufferHeads: Boolean # obsolete + checkFilesystemOnMount: Boolean + enableWriteBarriers: Boolean + enableHardwareDeleteCalls: Boolean + + allocator: ExtAllocator + reservedSpaceForGroup: Group + reservedSpaceForUser: User + superblockIndex: Int + inodeBlockReadAheadLimit: Int + stripeBlocks: Int + directorySizeLimit: ByteSize + + updateJournalFormat: Boolean + enableJournalChecksumming: Boolean + asynchronousJournalCommits: Boolean + processJournal: Boolean + journalInode: Int + journalDevice: String # FIXME: Translate this into a BlockDevice somehow? Expected 'number' format here is unclear. + journalPath: String + journalingMode: ExtJournalingMode + journalIOPriority: Int + onBufferError: ExtBufferErrorHandlingMode + commitInterval: TimeSize + + allowDeferredAllocations: Boolean + batchingTimeLimit: TimeSize + batchingTimeMinimum: TimeSize + enableMetadataBlockCache: Boolean + enableDirectIOReadLocking: Boolean + enableInodeTableBlockInitialization: Boolean + inodeTableBlockInitializationDelay: Int + automaticallySynchronizeBeforeRename: Boolean + + enableGroupQuota: Boolean + enableUserQuota: Boolean + enableProjectQuota: Boolean + quotaSystem: ExtQuotaSystem + userQuotaFile: String + groupQuotaFile: String + + # fat, vfat + blockSize: ByteSize + allocationTableBitness: Int + compressedVolumeFileModule: String + compressedVolumeFileOption: String + + defaultFileMode: Permissions + defaultFolderMode: Permissions + nameStrictness: FatNameStrictness + timestampChangesAllowedFrom: FatTimestampsAllowedFrom + restrictExecutableModeToWindowsBinaries: Boolean + treatAttrSysFlagAsImmutable: Boolean + + allowDifferentlyCasedNameConflicts: Boolean + shortNameMode: FatShortNameMode + preferShortNamesWithoutSequenceNumber: Boolean + enableUnicodeCharacterEscaping: Boolean + supportUtf8: Boolean + + enableReadOnlyFlagSupport: Boolean + enableDOS1xFallbackConfiguration: Boolean + enableQuietModeFailures: Boolean + enableNFSInodeCache: Boolean + enableEagerFlushing: Boolean + enableFreeClusterCache: Boolean + enableTimezoneConversion: Boolean + + timestampOffset: TimeSize + lineEndingConversionPolicy: FatLineEndingConversionPolicy + conversionCharacterSet: String + codepage: Int +} + +type Mount { + path: String! + rawOptions: [RawMountOption] + options: MountOptions +} + +type SmartAttributeFlags { + autoKeep: Boolean! + eventCount: Boolean! + errorRate: Boolean! + affectsPerformance: Boolean! + updatedOnline: Boolean! + indicatesFailure: Boolean! +} + +type SmartAttribute { + id: Int! + name: String! + flags: SmartAttributeFlags +} + +type BlockDevice { + name: String! + path: String! + mountpoint: String + deviceNumber: String! + removable: Boolean! + readOnly: Boolean! + size: ByteSize! + parent: BlockDevice + children: [BlockDevice!]! +} + +type PhysicalDrive { + path: String! + interface: String! + blockDevice: BlockDevice! + smartAvailable: Boolean! + smartEnabled: Boolean + model: String + modelFamily: String + serialNumber: String + wwn: String, + firmwareVersion: String + size: ByteSize + rpm: Int + logicalSectorSize: ByteSize + physicalSectorSize: ByteSize + formFactor: String + ataVersion: String + sataVersion: String + smartAttributes: [SmartAttribute!]! +} + +type LVMPhysicalVolume { + path: String! + blockDevice: BlockDevice! + volumeGroup: LVMVolumeGroup! + format: String! + size: ByteSize! + freeSpace: ByteSize! + duplicate: Boolean! + allocatable: Boolean! + used: Boolean! + exported: Boolean! + missing: Boolean! +} + +type LVMVolumeGroup { + name: String! +} + +type HardwareQuery { + drives(paths: [String]): [PhysicalDrive!]! +} + +type LVMQuery { + physicalVolumes: [LVMPhysicalVolume!]! + volumeGroups: [LVMVolumeGroup!]! +} + +type ResourcesQuery { + blockDevices: [BlockDevice!]! + lvm: LVMQuery +} + +type Query { + hardware: HardwareQuery! + resources: ResourcesQuery! +} \ No newline at end of file diff --git a/src/test-wrapper.js b/src/test-wrapper.js new file mode 100644 index 0000000..8a85a03 --- /dev/null +++ b/src/test-wrapper.js @@ -0,0 +1,47 @@ +"use strict"; + +const Promise = require("bluebird"); +const util = require("util"); + +const lsblk = require("./wrappers/lsblk"); +const lvm = require("./wrappers/lvm"); +const smartctl = require("./wrappers/smartctl"); +const findmnt = require("./wrappers/findmnt"); + +return Promise.try(() => { + // return lvm.getVersions(); + // return lvm.getPhysicalVolumes(); + // return lvm.createPhysicalVolume({ devicePath: "/dev/loop0" }); + // return lvm.createPhysicalVolume({ devicePath: process.argv[2] }); + // return lvm.createVolumeGroup({ name: "not a valid name", physicalVolumes: ["/dev/loop0", "/dev/asdfasdfasdf", "/dev/gasdfgasdf"] }); + // return lvm.createVolumeGroup({ name: "vg-name", physicalVolumes: ["/dev/loop0", "/dev/asdfasdfasdf", "/dev/gasdfgasdf"] }); + // return lvm.createVolumeGroup({ name: "vg-name", physicalVolumes: ["/dev/loop0", "/dev/loop1", "/dev/loop2"] }); + // return lvm.createVolumeGroup({ name: "vg-name", physicalVolumes: ["/dev/loop0", "/dev/loop1"] }); + // return lvm.createVolumeGroup({ name: "vg-name2", physicalVolumes: ["/dev/loop0", "/dev/loop1"] }); + // return lvm.addVolumeToVolumeGroup({ volumeGroup: "vg-name2", physicalVolume: "/dev/loop0" }); + // return lvm.addVolumeToVolumeGroup({ volumeGroup: "vg-name2", physicalVolume: "/dev/sfasdfasdfasdf" }); + // return lvm.addVolumeToVolumeGroup({ volumeGroup: "vg-name2", physicalVolume: "/dev/loop1" }); + // return lvm.addVolumeToVolumeGroup({ volumeGroup: "vg-name2", physicalVolume: "/dev/loop2" }); + // return lvm.addVolumeToVolumeGroup({ volumeGroup: "vg-name", physicalVolume: "/dev/loop1" }); + // return lvm.destroyPhysicalVolume({ devicePath: "/dev/loop0" }); + // return lsblk(); + // return smartctl.info({ devicePath: "/dev/sda" }) + // return smartctl.info({ devicePath: process.argv[2] }) + // return smartctl.attributes({ devicePath: process.argv[2] }) + return findmnt(); +}).then((result) => { + console.log(util.inspect(result, {colors: true, depth: null})); +}).catch((err) => { + if (err.getAllContext != null) { + let context = err.getAllContext() + console.log(context); + console.log("####################\n"); + } + + if (err.showChain != null) { + // console.log(err.showChain({ allStacktraces: true })); + console.log(err.showChain({})); + } else { + console.log(err.stack); + } +}); diff --git a/src/units/bytes/iec.js b/src/units/bytes/iec.js new file mode 100644 index 0000000..df4ebf8 --- /dev/null +++ b/src/units/bytes/iec.js @@ -0,0 +1,13 @@ +"use strict"; + +const makeUnits = require("../../make-units"); + +module.exports = makeUnits([ + {unit: "B", toNext: 1024}, + {unit: "KiB", toNext: 1024}, + {unit: "MiB", toNext: 1024}, + {unit: "GiB", toNext: 1024}, + {unit: "TiB", toNext: 1024}, + {unit: "PiB", toNext: 1024}, + {unit: "EiB"} +]); \ No newline at end of file diff --git a/src/units/time.js b/src/units/time.js new file mode 100644 index 0000000..4b8dd8b --- /dev/null +++ b/src/units/time.js @@ -0,0 +1,13 @@ +"use strict"; + +const makeUnits = require("../make-units"); + +module.exports = makeUnits([ + {unit: "nanoseconds", toNext: 1000}, + {unit: "microseconds", toNext: 1000}, + {unit: "milliseconds", toNext: 1000}, + {unit: "seconds", toNext: 60}, + {unit: "minutes", toNext: 60}, + {unit: "hours", toNext: 24}, + {unit: "days"} +]); \ No newline at end of file diff --git a/src/wrappers/findmnt.js b/src/wrappers/findmnt.js new file mode 100644 index 0000000..0653d63 --- /dev/null +++ b/src/wrappers/findmnt.js @@ -0,0 +1,80 @@ +"use strict"; + +const Promise = require("bluebird"); + +const execBinary = require("../exec-binary"); +const parseIECBytes = require("../parse/bytes/iec"); +const parseMountOptions = require("../parse/mount-options"); + +function mapMountList(mounts) { + return mounts.map((mount) => { + /* Some poorly-documented pseudo-filesystems were not worth investigating mount options for, yet. For those, we silently ignore missing/unknown entries. */ + let missingOptionsAllowed = ["cgroup", "cgroup2", "bpf", "pstore"].includes(mount.fstype); + + let parsedOptions = parseMountOptions(mount.fstype, mount.options); + + if (missingOptionsAllowed || parsedOptions.missing.length === 0) { + return { + id: mount.id, + sourceDevice: mount.source, + mountpoint: mount.target, + filesystem: mount.fstype, + options: parsedOptions.parsed, + label: mount.label, + uuid: mount.uuid, + partitionLabel: mount.partlabel, + partitionUUID: mount.partuuid, + deviceNumber: mount["maj:min"], + totalSpace: parseIECBytes(mount.size), + freeSpace: parseIECBytes(mount.avail), + usedSpace: parseIECBytes(mount.used), + rootPath: mount.fsroot, + taskID: mount.tid, + optionalFields: mount["opt-fields"], + propagationFlags: mount.propagation, + children: (mount.children != null) ? mapMountList(mount.children) : [] + }; + } else { + throw new Error(`Encountered unrecognized mount options for mount '${mount.target}': ${parsedOptions.missing.join(", ")}`); + } + }); +} + +let columns = [ + "SOURCE", + "TARGET", + "FSTYPE", + "OPTIONS", + "LABEL", + "UUID", + "PARTLABEL", + "PARTUUID", + "MAJ:MIN", + "SIZE", + "AVAIL", + "USED", + "FSROOT", + "TID", + "ID", + "OPT-FIELDS", + "PROPAGATION", + // "FREQ", + // "PASSNO" +]; + +module.exports = function findmnt() { + return Promise.try(() => { + return execBinary("findmnt") + .withFlags({ + json: true, + o: columns.join(",") + }) + .singleResult() + .expectJsonStdout((result) => { + return mapMountList(result.filesystems); + }) + .execute(); + }).then((output) => { + return output.result; + }); +}; \ No newline at end of file diff --git a/src/wrappers/lsblk.js b/src/wrappers/lsblk.js new file mode 100644 index 0000000..48ddcb9 --- /dev/null +++ b/src/wrappers/lsblk.js @@ -0,0 +1,51 @@ +"use strict"; + +const Promise = require("bluebird"); + +const execBinary = require("../exec-binary"); +const parseIECBytes = require("../parse/bytes/iec"); +const mapValue = require("../map-value"); + +function parseBoolean(value) { + return mapValue(value, { + 0: false, + 1: true + }); +} + +function mapType(value) { + return mapValue(value, { + part: "partition", + disk: "disk", + loop: "loopDevice" + }); +} + +function mapDeviceList(devices) { + return devices.map((device) => { + return { + name: device.name, + type: mapType(device.type), + mountpoint: device.mountpoint, + deviceNumber: device["maj:min"], + removable: parseBoolean(device.rm), + readOnly: parseBoolean(device.ro), + size: parseIECBytes(device.size), + children: (device.children != null) ? mapDeviceList(device.children) : [] + }; + }) +} + +module.exports = function lsblk() { + return Promise.try(() => { + return execBinary("lsblk") + .withFlags({ json: true }) + .singleResult() + .expectJsonStdout((result) => { + return mapDeviceList(result.blockdevices); + }) + .execute(); + }).then((output) => { + return output.result; + }); +}; \ No newline at end of file diff --git a/src/wrappers/lvm.js b/src/wrappers/lvm.js new file mode 100644 index 0000000..e4d393c --- /dev/null +++ b/src/wrappers/lvm.js @@ -0,0 +1,239 @@ +"use strict"; + +const Promise = require("bluebird"); + +const execBinary = require("../exec-binary"); +const errors = require("../errors"); +const parseIECBytes = require("../parse/bytes/iec"); + +function mapVersionTitle(title) { + if (title === "LVM version") { + return "lvm"; + } else if (title === "Library version") { + return "library"; + } else if (title === "Driver version") { + return "driver"; + } else if (title === "Configuration") { + return "configuration"; + } else { + throw new Error(`Unrecognized version type for LVM: ${title}`); + } +} + +function unattendedFlags(command) { + /* This will answer "no" to any safety prompts, cancelling the operation if safety issues arise. */ + return command.withFlags({ + q: [true, true] + }); +} + +function forceFlags(command) { + /* This will force-bypass safety checks, for when the administrator has indicated that they want to take the risk. */ + return command.withFlags({ + force: true + }); +} + +function asJson(resultMapper) { + return function (command) { + return command + .expectJsonStdout(resultMapper) + .withFlags({ + reportformat: "json" + }); + }; +} + +function hasFlag(flag) { + return function (error) { + if (error.getAllContext != null) { + let context = error.getAllContext(); + + /* The below counts *any* kind of non-null value as having a flag set, to accommodate matchAll scenarios and scenarios where the flag needs to contain further information. */ + return (context.result != null && context.result[flag] != null); + } + }; +} + +module.exports = { + getVersions: function () { + return Promise.try(() => { + return execBinary("lvm", ["version"]) + .asRoot() + .singleResult() + .expectStdout("versions", /^\s*([^:]+):\s*(.+)$/gm, { + required: true, + matchAll: true, + result: ([title, version]) => { + return { + key: mapVersionTitle(title), + value: version + }; + } + }) + .execute(); + }).then(({result}) => { + return result.reduce((object, entry) => { + return Object.assign(object, { + [entry.key]: entry.value + }); + }, {}); + }); + }, + getPhysicalVolumes: function () { + return Promise.try(() => { + return execBinary("pvs") + .asRoot() + .singleResult() + .withModifier(asJson((result) => { + return result.report[0].pv.map((volume) => { + return { + path: volume.pv_name, + volumeGroup: (volume.vg_name === "") ? null : volume.vg_name, + format: volume.pv_fmt, + totalSpace: parseIECBytes(volume.pv_size), + freeSpace: parseIECBytes(volume.pv_free), + isDuplicate: volume.pv_attr.includes("d"), + isAllocatable: volume.pv_attr.includes("a"), + isUsed: volume.pv_attr.includes("u"), + isExported: volume.pv_attr.includes("x"), + isMissing: volume.pv_attr.includes("m"), + }; + }); + })) + .execute(); + }).then((output) => { + return output.result; + }); + }, + createPhysicalVolume: function ({ devicePath, force }) { + return Promise.try(() => { + return execBinary("pvcreate", [devicePath]) + .asRoot() + .withModifier((force === true) ? forceFlags : unattendedFlags) + .expectStderr("deviceNotFound", /Device .+ not found\./, { result: () => true }) + .expectStderr("partitionTableExists", /WARNING: [a-z]+ signature detected on/, { result: () => true }) + .execute(); + }).then((_output) => { + return true; + }).catch(hasFlag("deviceNotFound"), (error) => { + throw errors.InvalidPath.chain(error, `Specified device '${devicePath}' does not exist`, { + path: devicePath + }); + }).catch(hasFlag("partitionTableExists"), (error) => { + throw errors.PartitionExists.chain(error, `Refused to create a Physical Volume, as a partition or partition table already exists on device '${devicePath}'`, { + path: devicePath + }); + }); + }, + destroyPhysicalVolume: function ({ devicePath }) { + return Promise.try(() => { + return execBinary("pvremove", [devicePath]) + .asRoot() + .atLeastOneResult() + .expectStdout("success", /Labels on physical volume "[^"]+" successfully wiped\./) + .expectStderr("deviceNotFound", /Device .+ not found\./, { result: () => true }) + .expectStderr("notAPhysicalVolume", /No PV label found on .+\./, { result: () => true }) + .execute(); + }).then((_output) => { + return true; + }).catch(hasFlag("deviceNotFound"), (error) => { + throw errors.InvalidPath.chain(error, `Specified device '${devicePath}' does not exist`, { + path: devicePath + }); + }).catch(hasFlag("notAPhysicalVolume"), (error) => { + throw errors.InvalidPath.chain(error, `Specified device '${devicePath}' is not a Physical Volume`, { + path: devicePath + }); + }); + }, + createVolumeGroup: function ({ name, physicalVolumes }) { + return Promise.try(() => { + if (/^[a-zA-Z0-9_][a-zA-Z0-9+_.-]*$/.test(name)) { + return execBinary("vgcreate", [name, ...physicalVolumes]) + .asRoot() + .withModifier(unattendedFlags) + .expectStderr("volumeGroupExists", /A volume group called ([^"]+) already exists\./, { result: () => true }) + .expectStderr("partitionTableExists", /WARNING: [a-z]+ signature detected on (.+) at offset/g, { + result: ([device]) => device, + matchAll: true + }) + .expectStderr("deviceNotFound", /Device (.+) not found\./g, { + result: ([device]) => device, + matchAll: true + }) + .expectStderr("physicalVolumeInUse", /Physical volume '([^']+)' is already in volume group '([^']+)'/g, { + result: ([device, volumeGroup]) => ({device, volumeGroup}), + matchAll: true + }) + .execute(); + } else { + throw new errors.InvalidName(`The specified Volume Group name '${name}' contains invalid characters`); + } + }).then((_output) => { + return true; + }).catch(hasFlag("deviceNotFound"), (error) => { + let failedDevices = error.getAllContext().result.deviceNotFound; + + throw errors.InvalidPath.chain(error, `The following specified devices do not exist: ${failedDevices.join(", ")}`, { + paths: failedDevices + }); + }).catch(hasFlag("partitionTableExists"), (error) => { + let failedDevices = error.getAllContext().result.partitionTableExists; + + throw errors.PartitionExists.chain(error, `Refused to create a Volume Group, as partitions or partition tables already exist on the following devices: ${failedDevices.join(", ")}`, { + paths: failedDevices + }); + }).catch(hasFlag("volumeGroupExists"), (error) => { + throw errors.VolumeGroupExists.chain(error, `A volume group with the name '${name}' already exists`, { + volumeGroupName: name + }); + }).catch(hasFlag("physicalVolumeInUse"), (error) => { + let failedItems = error.getAllContext().result.physicalVolumeInUse; + + let failedItemString = failedItems.map(({device, volumeGroup}) => { + return `${device} (${volumeGroup})`; + }).join(", "); + + throw errors.PhysicalVolumeInUse.chain(error, `The following specified Physical Volumes are already in use in another Volume Group: ${failedItemString}`, { + volumes: failedItems + }); + }); + }, + addVolumeToVolumeGroup: function ({ physicalVolume, volumeGroup }) { + return Promise.try(() => { + return execBinary("vgextend", [volumeGroup, physicalVolume]) + .asRoot() + .withModifier(unattendedFlags) + .expectStderr("deviceNotFound", /Device .+ not found\./, { result: () => true }) + .expectStderr("volumeGroupNotFound", /Volume group "[^"]+" not found/, { result: () => true }) + .expectStderr("partitionTableExists", /WARNING: [a-z]+ signature detected on/, { result: () => true }) + .expectStderr("physicalVolumeInUse", /Physical volume '([^']+)' is already in volume group '([^']+)'/, { + result: ([device, volumeGroup]) => ({device, volumeGroup}) + }) + .execute(); + }).then((_output) => { + return true; + }).catch(hasFlag("deviceNotFound"), (error) => { + throw errors.InvalidPath.chain(error, `Specified device '${physicalVolume}' does not exist`, { + path: physicalVolume + }); + }).catch(hasFlag("volumeGroupNotFound"), (error) => { + throw errors.InvalidVolumeGroup.chain(error, `Specified Volume Group '${volumeGroup}' does not exist`, { + volumeGroupName: volumeGroup + }); + }).catch(hasFlag("physicalVolumeInUse"), (error) => { + let volume = error.getAllContext().result.physicalVolumeInUse; + + throw errors.PhysicalVolumeInUse.chain(error, `Specified Physical Volume '${physicalVolume}' is already in use in another Volume Group (${volume.volumeGroup})`, { + volume: volume + }); + }).catch(hasFlag("partitionTableExists"), (error) => { + throw errors.PartitionExists.chain(error, `Refused to add device to Volume Group, as a partition or partition table already exists on device '${physicalVolume}'`, { + path: physicalVolume + }); + }); + } +}; + +// TODO: Need to check if cache service running? \ No newline at end of file diff --git a/src/wrappers/smartctl.js b/src/wrappers/smartctl.js new file mode 100644 index 0000000..1b542e4 --- /dev/null +++ b/src/wrappers/smartctl.js @@ -0,0 +1,158 @@ +"use strict"; + +const Promise = require("bluebird"); + +const execBinary = require("../exec-binary"); +const {B} = require("../units/bytes/iec"); +const matchOrError = require("../match-or-error"); +const errors = require("../errors"); +const mapValue = require("../map-value"); + +/* FIXME: Error handling, eg. device not found errors */ + +function mapAttributeFlags(flagString) { + + let flagBuffer = Buffer.from(flagString.slice(2), "hex"); + let flagByte = flagBuffer.readUInt16BE(0); + + if (flagByte & 128 || flagByte & 64) { + throw new Error(`Encountered unknown flag byte in flag ${flagString}`); + } else { + return { + autoKeep: Boolean(flagByte & 32), + eventCount: Boolean(flagByte & 16), + errorRate: Boolean(flagByte & 8), + affectsPerformance: Boolean(flagByte & 4), + updatedOnline: Boolean(flagByte & 2), + indicatesFailure: Boolean(flagByte & 1), + }; + } +} + +module.exports = { + attributes: function ({ devicePath }) { + return Promise.try(() => { + return execBinary("smartctl", [devicePath]) + .asRoot() + .withFlags({ attributes: true }) + .singleResult() + .expectStdout("attributes", /^\s*([0-9]+)\s+([a-zA-Z_-]+)\s+(0x[0-9a-f]{4})\s+([0-9]{3})\s+([0-9]{3})\s+([0-9]{3})\s+(Pre-fail|Old_age)\s+(Always|Offline)\s+(FAILING_NOW|In_the_past|-)\s+(.+)$/gm, { + required: true, + matchAll: true, + result: ([id, attributeName, flags, value, worst, threshold, type, updatedWhen, failedWhen, rawValue]) => { + return { + id: parseInt(id), + name: attributeName, + flags: mapAttributeFlags(flags), + value: parseInt(value), + rawValue: rawValue, + worstValueSeen: parseInt(worst), + failureThreshold: parseInt(threshold), + type: mapValue(type, { + "Pre-fail": "preFail", + "Old_age": "oldAge" + }), + failingNow: (failedWhen === "FAILING_NOW"), + failedBefore: (failedWhen === "In_the_past"), + updatedWhen: mapValue(updatedWhen, { + "Always": "always", + "Offline": "offline" + }) + }; + } + }) + .execute(); + }).then((output) => { + return output.result; + }); + }, + info: function ({ devicePath }) { + return Promise.try(() => { + return execBinary("smartctl", [devicePath]) + .asRoot() + .withFlags({ info: true }) + .expectStdout("smartAvailable", /^SMART support is:\s*(Available|Unavailable|Ambiguous).+$/m, { + result: ([availability]) => { + return mapValue(availability, { + Available: true, + Unavailable: false, + Ambiguous: null + }); + } + }) + .expectStdout("model", /^Device Model:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("modelFamily", /^Model Family:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("serialNumber", /^Serial Number:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("wwn", /^LU WWN Device Id:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("firmwareVersion", /^Firmware Version:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("size", /^User Capacity:\s*(.+)$/m, { + result: ([value]) => { + try { + let match = matchOrError(/^([0-9,]+) bytes \[[^\]]+\]$/, value); + return B(parseInt(match[0].replace(/,/g, ""))); + } catch (error) { + throw errors.UnexpectedOutput.chain(error, "Could not parse drive capacity", { input: value }); + } + } + }) + .expectStdout("rpm", /^Rotation Rate:\s*(.+)$/m, { + result: ([value]) => { + try { + let match = matchOrError(/^([0-9]+) rpm$/, value); + return parseInt(match[0]); + } catch (error) { + throw errors.UnexpectedOutput.chain(error, "Could not parse drive RPM", { input: value }); + } + } + }) + .expectStdout("sectorSizes", /^Sector Sizes:\s*(.+)$/m, { + result: ([value]) => { + try { + let match = matchOrError(/^([0-9]+) bytes logical, ([0-9]+) bytes physical$/, value); + + return { + logical: B(parseInt(match[0])), + physical: B(parseInt(match[1])) + }; + } catch (error) { + throw errors.UnexpectedOutput.chain(error, "Could not parse drive sector sizes", { input: value }); + } + } + }) + .expectStdout("formFactor", /^Form Factor:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("ataVersion", /^ATA Version is:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("sataVersion", /^SATA Version is:\s*(.+)$/m, { result: ([value]) => value }) + .expectStdout("smartEnabled", /^SMART support is:\s*(Enabled|Disabled)$/m, { + result: ([value]) => { + return mapValue(value, { + Enabled: true, + Disabled: false + }); + } + }) + .execute(); + }).then((output) => { + return output.result; + }); + }, + scan: function () { + return Promise.try(() => { + return execBinary("smartctl") + .asRoot() + .withFlags({ scan: true }) + .singleResult() + .expectStdout("devices", /^([^ ]+) -d ([^ ]+) #.+$/gm, { + matchAll: true, + result: ([devicePath, interface_]) => { + return { + path: devicePath, + interface: interface_ + }; + } + }) + .execute(); + }).then((output) => { + return output.result; + }); + } +}; \ No newline at end of file