Browse Source

WIP: Hardware query API

feature/node-rewrite
Sven Slootweg 1 year ago
parent
commit
7244a506ae
19 changed files with 2537 additions and 4 deletions
  1. +3
    -0
      .graphqlconfig
  2. +23
    -4
      package.json
  3. +27
    -0
      src/errors.js
  4. +375
    -0
      src/exec-binary.js
  5. +490
    -0
      src/graphql-test.js
  6. +68
    -0
      src/make-units.js
  7. +11
    -0
      src/map-value.js
  8. +17
    -0
      src/match-or-error.js
  9. +44
    -0
      src/parse/bytes/iec.js
  10. +469
    -0
      src/parse/mount-options.js
  11. +80
    -0
      src/parse/octal-mode.js
  12. +329
    -0
      src/schemas/main.gql
  13. +47
    -0
      src/test-wrapper.js
  14. +13
    -0
      src/units/bytes/iec.js
  15. +13
    -0
      src/units/time.js
  16. +80
    -0
      src/wrappers/findmnt.js
  17. +51
    -0
      src/wrappers/lsblk.js
  18. +239
    -0
      src/wrappers/lvm.js
  19. +158
    -0
      src/wrappers/smartctl.js

+ 3
- 0
.graphqlconfig View File

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

+ 23
- 4
package.json View File

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


+ 27
- 0
src/errors.js View File

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

+ 375
- 0
src/exec-binary.js View File

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

+ 490
- 0
src/graphql-test.js View File

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

+ 68
- 0
src/make-units.js View File

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

+ 11
- 0
src/map-value.js View File

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

+ 17
- 0
src/match-or-error.js View File

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

+ 44
- 0
src/parse/bytes/iec.js View File

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

+ 469
- 0
src/parse/mount-options.js View File

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

+ 80
- 0
src/parse/octal-mode.js View File

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

+ 329
- 0
src/schemas/main.gql View File

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

+ 47
- 0
src/test-wrapper.js View File

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

+ 13
- 0
src/units/bytes/iec.js View File

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

+ 13
- 0
src/units/time.js View File

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

+ 80
- 0
src/wrappers/findmnt.js View File

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