WIP: Hardware query API

feature/node-rewrite
Sven Slootweg 5 years ago
parent 9c31b491d5
commit 7244a506ae

@ -0,0 +1,3 @@
{
"schemaPath": "src/schemas/**/*.gql"
}

@ -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",

@ -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"),
};

@ -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
}));
});
}
};
};

@ -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);
});

@ -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 = `<Unit> ${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;
};

@ -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}`);
}
};

@ -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);
}
}
};

@ -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));
}
};

@ -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: [] });
};

@ -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}`);
}
}

@ -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!
}

@ -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);
}
});

@ -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"}
]);

@ -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"}
]);

@ -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;
});
};

@ -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;
});
};

@ -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?

@ -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;
});
}
};
Loading…
Cancel
Save